From f6b8eae0070d8039eb24739981a3a1e8fd8d6625 Mon Sep 17 00:00:00 2001 From: Michael Lamers Date: Mon, 29 Dec 2025 08:07:33 +0100 Subject: [PATCH 1/5] Add settings ex-/import improve Intent selector (multi-select, search) bump Gradle --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 3 +- .../service/LauncherOverlayService.kt | 6 +- .../paperlaunch/storage/DataExporter.kt | 73 +++++ .../paperlaunch/storage/DataImporter.kt | 81 ++++++ .../view/fragments/EditFolderFragment.kt | 36 ++- .../view/fragments/SettingsFragment.kt | 97 +++++++ .../paperlaunch/view/utils/IntentSelector.kt | 255 +++++++++++++----- app/src/main/res/drawable/ic_check.xml | 9 + .../layout/common__intentselectorgroup.xml | 3 +- .../res/layout/common__intentselectoritem.xml | 68 +++-- .../res/layout/common__intentselectorview.xml | 13 +- app/src/main/res/layout/toolbar_appcompat.xml | 7 + .../main/res/menu/menu_intent_selector.xml | 10 + app/src/main/res/values/strings.xml | 10 + app/src/main/res/values/styles.xml | 10 + build.gradle | 2 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 18 files changed, 587 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/de/devmil/paperlaunch/storage/DataExporter.kt create mode 100644 app/src/main/java/de/devmil/paperlaunch/storage/DataImporter.kt create mode 100644 app/src/main/res/drawable/ic_check.xml create mode 100644 app/src/main/res/layout/toolbar_appcompat.xml create mode 100644 app/src/main/res/menu/menu_intent_selector.xml diff --git a/app/build.gradle b/app/build.gradle index eff24ff..6975501 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -60,6 +60,7 @@ dependencies { implementation 'io.reactivex:rxandroid:1.1.0' implementation 'io.reactivex:rxjava:1.1.0' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation 'com.google.code.gson:gson:2.10.1' implementation 'com.android.support.constraint:constraint-layout:2.0.4' testImplementation 'junit:junit:4.13.2' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bf582af..7a0725f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,7 +28,8 @@ + android:label="@string/activity_intentselector_label" + android:theme="@style/Theme.PaperLaunch.NoActionBar"/> diff --git a/app/src/main/java/de/devmil/paperlaunch/service/LauncherOverlayService.kt b/app/src/main/java/de/devmil/paperlaunch/service/LauncherOverlayService.kt index c75c198..b702e0a 100644 --- a/app/src/main/java/de/devmil/paperlaunch/service/LauncherOverlayService.kt +++ b/app/src/main/java/de/devmil/paperlaunch/service/LauncherOverlayService.kt @@ -174,8 +174,12 @@ class LauncherOverlayService : Service() { } private fun adaptState(forceReload: Boolean) { + if (forceReload) { + resetConfig() + resetData() + } if (state.isActive) { - ensureOverlayActive(forceReload) + ensureOverlayActive(false) } else { ensureOverlayInActive() } diff --git a/app/src/main/java/de/devmil/paperlaunch/storage/DataExporter.kt b/app/src/main/java/de/devmil/paperlaunch/storage/DataExporter.kt new file mode 100644 index 0000000..bf5688e --- /dev/null +++ b/app/src/main/java/de/devmil/paperlaunch/storage/DataExporter.kt @@ -0,0 +1,73 @@ +package de.devmil.paperlaunch.storage + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.util.Base64 +import com.google.gson.Gson +import de.devmil.paperlaunch.model.IFolder +import de.devmil.paperlaunch.model.Launch +import de.devmil.paperlaunch.utils.BitmapUtils +import de.devmil.paperlaunch.utils.IntentSerializer +import java.io.ByteArrayOutputStream + +class DataExporter(private val context: Context) { + + data class ExportData( + val version: Int = 1, + val entries: List + ) + + data class ExportEntry( + val type: String, // "folder" or "launch" + val name: String?, + val icon: String?, // Base64 encoded png + val intentUri: String?, // Only for launch + val entries: List? // Only for folder + ) + + fun exportToJson(): String { + var rootEntries: List = emptyList() + EntriesDataSource.instance.accessData(context, object : ITransactionAction { + override fun execute(transactionContext: ITransactionContext) { + val roots = transactionContext.loadRootContent() + rootEntries = roots.map { convertToExportEntry(it) } + } + }) + + val exportData = ExportData(entries = rootEntries) + return Gson().toJson(exportData) + } + + private fun convertToExportEntry(entry: de.devmil.paperlaunch.model.IEntry): ExportEntry { + if (entry.isFolder) { + val folder = entry as IFolder + val subEntries = folder.subEntries?.map { convertToExportEntry(it) } ?: emptyList() + return ExportEntry( + type = "folder", + name = folder.name, + icon = encodeIcon(folder.icon?.let { if (it is BitmapDrawable) it else null }), // Only encode if it's a BitmapDrawable (custom icon), otherwise null implies default + intentUri = null, + entries = subEntries + ) + } else { + val launch = entry as Launch + return ExportEntry( + type = "launch", + name = launch.name, + icon = encodeIcon(launch.dto.icon?.let { if (it is BitmapDrawable) it else null }), + intentUri = IntentSerializer.serialize(launch.dto.launchIntent), + entries = null + ) + } + } + + private fun encodeIcon(drawable: BitmapDrawable?): String? { + if (drawable == null) return null + val bitmap = drawable.bitmap ?: return null + val stream = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream) + val byteArray = stream.toByteArray() + return Base64.encodeToString(byteArray, Base64.DEFAULT) + } +} diff --git a/app/src/main/java/de/devmil/paperlaunch/storage/DataImporter.kt b/app/src/main/java/de/devmil/paperlaunch/storage/DataImporter.kt new file mode 100644 index 0000000..3415d79 --- /dev/null +++ b/app/src/main/java/de/devmil/paperlaunch/storage/DataImporter.kt @@ -0,0 +1,81 @@ +package de.devmil.paperlaunch.storage + +import android.content.Context +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.util.Base64 +import com.google.gson.Gson +import de.devmil.paperlaunch.utils.IntentSerializer +import java.lang.Exception + +class DataImporter(private val context: Context) { + + data class ExportData( + val version: Int = 1, + val entries: List + ) + + data class ExportEntry( + val type: String, + val name: String?, + val icon: String?, + val intentUri: String?, + val entries: List? + ) + + fun importFromJson(json: String) { + val data = Gson().fromJson(json, ExportData::class.java) + + EntriesDataSource.instance.accessData(context, object : ITransactionAction { + override fun execute(transactionContext: ITransactionContext) { + // Clear existing data + transactionContext.clear() + + // Restore data + data.entries.forEachIndexed { index, entry -> + restoreEntry(transactionContext, -1, entry, index, 0) + } + } + }) + } + + private fun restoreEntry( + transactionContext: ITransactionContext, + parentFolderId: Long, + entry: ExportEntry, + orderIndex: Int, + depth: Int + ) { + if (entry.type == "folder") { + val folder = transactionContext.createFolder(parentFolderId, orderIndex, depth) + folder.dto.name = entry.name + if (entry.icon != null) { + folder.dto.icon = decodeIcon(entry.icon) + } + transactionContext.updateFolderData(folder) + + entry.entries?.forEachIndexed { index, childEntry -> + restoreEntry(transactionContext, folder.id, childEntry, index, depth + 1) + } + } else if (entry.type == "launch") { + val launch = transactionContext.createLaunch(parentFolderId, orderIndex) + launch.dto.name = entry.name + launch.dto.launchIntent = IntentSerializer.deserialize(entry.intentUri) + if (entry.icon != null) { + launch.dto.icon = decodeIcon(entry.icon) + } + transactionContext.updateLaunchData(launch) + } + } + + private fun decodeIcon(base64: String): BitmapDrawable? { + return try { + val bytes = Base64.decode(base64, Base64.DEFAULT) + val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + BitmapDrawable(context.resources, bitmap) + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} diff --git a/app/src/main/java/de/devmil/paperlaunch/view/fragments/EditFolderFragment.kt b/app/src/main/java/de/devmil/paperlaunch/view/fragments/EditFolderFragment.kt index f666181..1686902 100644 --- a/app/src/main/java/de/devmil/paperlaunch/view/fragments/EditFolderFragment.kt +++ b/app/src/main/java/de/devmil/paperlaunch/view/fragments/EditFolderFragment.kt @@ -103,6 +103,7 @@ class EditFolderFragment : Fragment() { override fun onResume() { super.onResume() bottomSheetBehavior?.state = BottomSheetBehavior.STATE_HIDDEN + loadData() } @Deprecated("Deprecated in Java") @@ -310,6 +311,7 @@ class EditFolderFragment : Fragment() { intent.setClass(activity, IntentSelector::class.java) intent.putExtra(IntentSelector.EXTRA_STRING_ACTIVITIES, resources.getString(R.string.folder_settings_add_app_activities)) intent.putExtra(IntentSelector.EXTRA_STRING_SHORTCUTS, resources.getString(R.string.folder_settings_add_app_shortcuts)) + intent.putExtra(IntentSelector.EXTRA_ALLOW_MULTI_SELECT, true) startActivityForResult(intent, REQUEST_ADD_APP) } @@ -331,7 +333,12 @@ class EditFolderFragment : Fragment() { return } if(data != null) { - addLaunch(data) + if (data.hasExtra(IntentSelector.EXTRA_RESULT_INTENTS)) { + val list = data.getParcelableArrayListExtra(IntentSelector.EXTRA_RESULT_INTENTS) + list?.let { addLaunches(it) } + } else { + addLaunch(data) + } } } REQUEST_EDIT_FOLDER -> { @@ -358,6 +365,27 @@ class EditFolderFragment : Fragment() { notifyDataChanged() } + private fun addLaunches(launchIntents: List) { + EntriesDataSource.instance.accessData(activity, object: ITransactionAction { + override fun execute(transactionContext: ITransactionContext) { + adapter?.let { itAdapter -> + val newEntries = ArrayList() + for (launchIntent in launchIntents) { + val l = transactionContext.createLaunch(folderId) + l.dto.launchIntent = launchIntent + transactionContext.updateLaunchData(l) + newEntries.add(l) + } + itAdapter.addEntries(newEntries) + folder?.let { itFolder -> + updateFolderImage(itFolder.dto, itAdapter.entries) + } + } + } + }) + notifyDataChanged() + } + private fun updateFolderImage(folderDto: FolderDTO, entries: List) { config?.let { itConfig -> val imgWidth = itConfig.imageWidthDip @@ -434,6 +462,12 @@ class EditFolderFragment : Fragment() { notifyDataSetChanged() } + fun addEntries(entries: List) { + mEntries.addAll(entries) + saveOrder() + notifyDataSetChanged() + } + override fun getPositionForId(id: Long): Int { return mEntries.indices.firstOrNull { mEntries[it].entryId == id } ?: -1 } diff --git a/app/src/main/java/de/devmil/paperlaunch/view/fragments/SettingsFragment.kt b/app/src/main/java/de/devmil/paperlaunch/view/fragments/SettingsFragment.kt index 3c9b898..c5953c9 100644 --- a/app/src/main/java/de/devmil/paperlaunch/view/fragments/SettingsFragment.kt +++ b/app/src/main/java/de/devmil/paperlaunch/view/fragments/SettingsFragment.kt @@ -31,7 +31,12 @@ import de.devmil.paperlaunch.R import de.devmil.paperlaunch.config.LauncherGravity import de.devmil.paperlaunch.config.UserSettings import de.devmil.paperlaunch.service.LauncherOverlayService +import de.devmil.paperlaunch.storage.DataExporter +import de.devmil.paperlaunch.storage.DataImporter import de.devmil.paperlaunch.view.preferences.SeekBarPreference +import android.content.Intent +import android.app.Activity +import android.widget.Toast class SettingsFragment : PreferenceFragment() { @@ -51,6 +56,53 @@ class SettingsFragment : PreferenceFragment() { addActivationSettings(context, screen) addAppearanceSettings(context, screen) + addBackupSettings(context, screen) + } + + @Deprecated("Deprecated in Java") + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (resultCode == Activity.RESULT_OK && data != null && data.data != null) { + val uri = data.data!! + if (requestCode == REQUEST_CODE_EXPORT) { + Thread { + try { + val json = DataExporter(activity).exportToJson() + activity.contentResolver.openOutputStream(uri)?.use { output -> + output.write(json.toByteArray()) + } + activity.runOnUiThread { + Toast.makeText(activity, R.string.fragment_settings_backup_export_success, Toast.LENGTH_SHORT).show() + } + } catch (e: Exception) { + e.printStackTrace() + activity.runOnUiThread { + Toast.makeText(activity, R.string.fragment_settings_backup_export_error, Toast.LENGTH_SHORT).show() + } + } + }.start() + } else if (requestCode == REQUEST_CODE_IMPORT) { + Thread { + try { + val json = activity.contentResolver.openInputStream(uri)?.use { input -> + input.bufferedReader().use { it.readText() } + } + if (json != null) { + DataImporter(activity).importFromJson(json) + activity.runOnUiThread { + Toast.makeText(activity, R.string.fragment_settings_backup_import_success, Toast.LENGTH_SHORT).show() + LauncherOverlayService.notifyDataChanged(activity) + } + } + } catch (e: Exception) { + e.printStackTrace() + activity.runOnUiThread { + Toast.makeText(activity, R.string.fragment_settings_backup_import_error, Toast.LENGTH_SHORT).show() + } + } + }.start() + } + } } fun setOnActivationParametersChangedListener(listener: () -> Unit) { @@ -304,4 +356,49 @@ class SettingsFragment : PreferenceFragment() { } } } + + private fun addBackupSettings(context: Context, screen: PreferenceScreen) { + val backupCategory = PreferenceCategory(context) + screen.addPreference(backupCategory) + + backupCategory.isPersistent = false + backupCategory.setTitle(R.string.fragment_settings_category_backup_title) + + val exportPreference = Preference(context) + backupCategory.addPreference(exportPreference) + exportPreference.setTitle(R.string.fragment_settings_backup_export_title) + exportPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/json" + putExtra(Intent.EXTRA_TITLE, "paperlaunch_backup.json") + } + startActivityForResult(intent, REQUEST_CODE_EXPORT) + true + } + + val importPreference = Preference(context) + backupCategory.addPreference(importPreference) + importPreference.setTitle(R.string.fragment_settings_backup_import_title) + importPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + android.app.AlertDialog.Builder(activity) + .setTitle(R.string.fragment_settings_backup_import_warning_title) + .setMessage(R.string.fragment_settings_backup_import_warning_message) + .setPositiveButton(android.R.string.ok) { _, _ -> + val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "application/json" + } + startActivityForResult(intent, REQUEST_CODE_IMPORT) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + true + } + } + + companion object { + private const val REQUEST_CODE_EXPORT = 101 + private const val REQUEST_CODE_IMPORT = 102 + } } diff --git a/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentSelector.kt b/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentSelector.kt index 3e11568..f891124 100644 --- a/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentSelector.kt +++ b/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentSelector.kt @@ -16,6 +16,11 @@ package de.devmil.paperlaunch.view.utils import android.app.Activity +import android.support.v7.app.AppCompatActivity +import android.support.v7.widget.Toolbar +import android.support.v7.widget.SearchView +import android.view.Menu +import android.view.MenuItem import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -32,7 +37,7 @@ import de.devmil.paperlaunch.R import java.lang.ref.WeakReference import java.util.* -class IntentSelector : Activity() { +class IntentSelector : AppCompatActivity(), SearchView.OnQueryTextListener { private var llWait: LinearLayout? = null private var lvActivities: ExpandableListView? = null @@ -52,11 +57,6 @@ class IntentSelector : Activity() { @Deprecated("Deprecated in Java") override fun doInBackground(vararg params: Unit?) { - //this approach can kill the PackageManager if there are too many apps installed - // List shortcutResolved = getPackageManager().queryIntentActivities(shortcutIntent, PackageManager.GET_ACTIVITIES | PackageManager.GET_INTENT_FILTERS); - // List mainResolved = getPackageManager().queryIntentActivities(mainIntent, PackageManager.GET_ACTIVITIES | PackageManager.GET_INTENT_FILTERS); - // List launcherResolved = getPackageManager().queryIntentActivities(launcherIntent, PackageManager.GET_ACTIVITIES | PackageManager.GET_INTENT_FILTERS); - intentSelectorRef.get()?.let { val pm = it.packageManager @@ -64,75 +64,64 @@ class IntentSelector : Activity() { val mainResolved = ArrayList() val launcherResolved = ArrayList() - val appInfos = pm.getInstalledApplications(PackageManager.GET_META_DATA) - val showAll = it.chkShowAllActivities!!.isChecked - for (appInfo in appInfos) { - if(isCancelled || isObsolete) { - return - } - val shortcutIntent = Intent(Intent.ACTION_CREATE_SHORTCUT) - shortcutIntent.`package` = appInfo.packageName - - val appShortcutResolved = pm.queryIntentActivities(shortcutIntent, PackageManager.GET_META_DATA) - shortcutResolved.addAll(appShortcutResolved) + // Instead of getInstalledApplications, we query intents directly for better performance - var addMainActivities = true - - if (showAll) { - try { - val pi = pm.getPackageInfo(appInfo.packageName, PackageManager.GET_ACTIVITIES or PackageManager.GET_INTENT_FILTERS) - pi.activities?.let { it -> - for (ai in it) { - val ri = ResolveInfo() - ri.activityInfo = ai - - mainResolved.add(ri) - } - - } - - addMainActivities = false - } catch (e: Exception) { - } + // 1. Launcher Activities (Always needed) + val launcherIntent = Intent(Intent.ACTION_MAIN) + launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER) + try { + launcherResolved.addAll(pm.queryIntentActivities(launcherIntent, 0)) + } catch (e: Exception) { + Log.e(TAG, "Error querying launcher activities", e) + } - } + if (isCancelled || isObsolete) return - if (addMainActivities) { - val mainIntent = Intent(Intent.ACTION_MAIN) - mainIntent.`package` = appInfo.packageName + // 2. Shortcuts (Always needed) + val shortcutIntent = Intent(Intent.ACTION_CREATE_SHORTCUT) + try { + shortcutResolved.addAll(pm.queryIntentActivities(shortcutIntent, 0)) + } catch (e: Exception) { + Log.e(TAG, "Error querying shortcut activities", e) + } - val appMainResolved = pm.queryIntentActivities(mainIntent, PackageManager.GET_META_DATA) - mainResolved.addAll(appMainResolved) + if (isCancelled || isObsolete) return + + // 3. Main Activities (Only if showAll is true) + // Note: Querying ACTION_MAIN without category can be huge, but if user requests it... + // We can optimize by iterating launcherResolved/shortcutResolved to find packages? + // Or just query only if checked. + if (showAll) { + val mainIntent = Intent(Intent.ACTION_MAIN) + try { + mainResolved.addAll(pm.queryIntentActivities(mainIntent, 0)) + } catch (e: Exception) { + Log.e(TAG, "Error querying main activities", e) } + } - val launcherIntent = Intent(Intent.ACTION_MAIN) - launcherIntent.addCategory(Intent.CATEGORY_LAUNCHER) - launcherIntent.`package` = appInfo.packageName - - val appLauncherResolved = pm.queryIntentActivities(launcherIntent, PackageManager.GET_META_DATA) - launcherResolved.addAll(appLauncherResolved) + // Process Launcher Activities + for (ri in launcherResolved) { + if (isCancelled || isObsolete) return + addResolveInfo(ri, IntentApplicationEntry.IntentType.Launcher, false, entries) } + // Process Shortcut Activities for (ri in shortcutResolved) { - if(isCancelled || isObsolete) { - return - } - addResolveInfo(ri, IntentApplicationEntry.IntentType.Shortcut, false, entries) - } - for (ri in mainResolved) { - if(isCancelled || isObsolete) { - return - } - addResolveInfo(ri, IntentApplicationEntry.IntentType.Main, showAll, entries) + if (isCancelled || isObsolete) return + addResolveInfo(ri, IntentApplicationEntry.IntentType.Shortcut, false, entries) } - for (ri in launcherResolved) { - if(isCancelled || isObsolete) { - return + + // Process Main Activities + if (showAll) { + for (ri in mainResolved) { + if (isCancelled || isObsolete) return + addResolveInfo(ri, IntentApplicationEntry.IntentType.Main, true, entries) } - addResolveInfo(ri, IntentApplicationEntry.IntentType.Launcher, false, entries) } + //sort val comparator = Comparator { object1, object2 -> object1.compareTo(object2) } entries.sortWith(comparator) @@ -158,6 +147,9 @@ class IntentSelector : Activity() { localIntentSelector.lvActivities!!.setAdapter(localIntentSelector.adapterActivities) localIntentSelector.lvShortcuts!!.setAdapter(localIntentSelector.adapterShortcuts) + + localIntentSelector.adapterActivities?.filter(localIntentSelector.mCurrentQuery) + localIntentSelector.adapterShortcuts?.filter(localIntentSelector.mCurrentQuery) } if(!isAnotherSearchRunning) { localIntentSelector.llWait!!.visibility = View.GONE @@ -196,15 +188,19 @@ class IntentSelector : Activity() { private var intentSelectorRef: WeakReference = WeakReference(intentSelector) } - private var mSearchTask : SearchTask? = null + private var adapterActivities: IntentSelectorAdapter? = null + private var adapterShortcuts: IntentSelectorAdapter? = null - internal var adapterActivities: IntentSelectorAdapter? = null - internal var adapterShortcuts: IntentSelectorAdapter? = null + private var fabDone: android.support.design.widget.FloatingActionButton? = null + private var allowMultiSelect: Boolean = false + private val selectedIntents = ArrayList() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + allowMultiSelect = intent.getBooleanExtra(EXTRA_ALLOW_MULTI_SELECT, false) + if(mSearchTask != null) { mSearchTask!!.cancel(true) mSearchTask = null @@ -231,26 +227,50 @@ class IntentSelector : Activity() { txtShortcuts = findViewById(R.id.common__intentSelector_txtShortcuts) toolbar = findViewById(R.id.common__intentSelector_toolbar) - setActionBar(toolbar) + setSupportActionBar(toolbar) txtShortcuts!!.text = shortcutText lvActivities!!.setOnChildClickListener { _, _, groupPosition, childPosition, _ -> - val resultIntent = Intent(Intent.ACTION_MAIN) val entry = adapterActivities!!.getChild(groupPosition, childPosition) as IntentApplicationEntry.IntentItem + val resultIntent = Intent(Intent.ACTION_MAIN) resultIntent.setClassName(entry.packageName, entry.activityName) - setResultIntent(resultIntent) - true + + if (allowMultiSelect) { + toggleSelection(resultIntent) + adapterActivities!!.notifyDataSetChanged() + true + } else { + setResultIntent(resultIntent) + true + } } chkShowAllActivities!!.setOnCheckedChangeListener { _, _ -> startSearch() } lvShortcuts!!.setOnChildClickListener { _, _, groupPosition, childPosition, _ -> - val shortcutIntent = Intent(Intent.ACTION_CREATE_SHORTCUT) val entry = adapterShortcuts!!.getChild(groupPosition, childPosition) as IntentApplicationEntry.IntentItem + val shortcutIntent = Intent(Intent.ACTION_CREATE_SHORTCUT) shortcutIntent.setClassName(entry.packageName, entry.activityName) startActivityForResult(shortcutIntent, CREATE_SHORTCUT_REQUEST) false } + fabDone = findViewById(R.id.common__intentSelector_fab) + if (allowMultiSelect) { + fabDone!!.show() + fabDone!!.setOnClickListener { + val resultIntent = Intent() + resultIntent.putParcelableArrayListExtra(EXTRA_RESULT_INTENTS, selectedIntents) + setResult(Activity.RESULT_OK, resultIntent) + finish() + } + // Keep tabs but maybe hide shortcuts for multiselect if not supported? + // Assuming shortcuts are not supported in multi-select for now or handled same way? + // Existing logic launches helper activity for shortcuts. This breaks multi-select flow unless handled. + // For now, let's just make Multi-Select work for Apps which is the performance killer. + } else { + fabDone!!.hide() + } + val tabs = this.findViewById(android.R.id.tabhost) tabs.setup() val tspecActivities = tabs.newTabSpec(activitiesLabel) @@ -273,6 +293,27 @@ class IntentSelector : Activity() { startSearch() } + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menuInflater.inflate(R.menu.menu_intent_selector, menu) + val searchItem = menu.findItem(R.id.action_search) + val searchView = searchItem.actionView as SearchView + searchView.setOnQueryTextListener(this) + return true + } + + override fun onQueryTextSubmit(query: String?): Boolean { + return false + } + + private var mCurrentQuery: String? = null + + override fun onQueryTextChange(newText: String?): Boolean { + mCurrentQuery = newText + adapterActivities?.filter(newText) + adapterShortcuts?.filter(newText) + return true + } + private fun startSearch() { if (mSearchTask != null) { mSearchTask!!.isAnotherSearchRunning = true @@ -300,13 +341,65 @@ class IntentSelector : Activity() { super.onActivityResult(requestCode, resultCode, data) } + private fun toggleSelection(intent: Intent) { + // Simple check based on component name + val component = intent.component ?: return + val existing = selectedIntents.find { it.component == component } + if (existing != null) { + selectedIntents.remove(existing) + } else { + selectedIntents.add(intent) + } + updateTitleInternal() + } + + private fun updateTitleInternal() { + if (!allowMultiSelect) return + if (selectedIntents.size > 0) { + title = getString(R.string.activity_intentselector_title_count, selectedIntents.size) + } else { + title = getString(R.string.activity_intentselector_label) + } + } + + private fun isSelected(intent: Intent): Boolean { + val component = intent.component ?: return false + return selectedIntents.any { it.component == component } + } + internal class IntentSelectorAdapter(private val context: Context, entriesList: List, private val intentType: IntentApplicationEntry.IntentType) : BaseExpandableListAdapter() { - private val entries: MutableList + private var entries: MutableList + private val originalEntries: List init { - this.entries = entriesList + this.originalEntries = entriesList .filter { getSubList(it).isNotEmpty() } - .toMutableList() + this.entries = this.originalEntries.toMutableList() + } + + fun filter(query: String?) { + entries.clear() + if (query.isNullOrEmpty()) { + entries.addAll(originalEntries) + } else { + val q = query.lowercase(Locale.getDefault()) + for (entry in originalEntries) { + var matches = false + if (entry.name.toString().lowercase(Locale.getDefault()).contains(q)) { + matches = true + } else { + val subList = getSubList(entry) + if (subList.any { it.displayName.lowercase(Locale.getDefault()).contains(q) }) { + matches = true + } + } + + if (matches) { + entries.add(entry) + } + } + } + notifyDataSetChanged() } fun getSubList(entry: IntentApplicationEntry): List { @@ -329,9 +422,21 @@ class IntentSelector : Activity() { } val txt = effectiveConvertView!!.findViewById(R.id.common__intentselectoritem_text) val txtActivityName = effectiveConvertView.findViewById(R.id.common__intentselectoritem_activityName) + val checkbox = effectiveConvertView.findViewById(R.id.common__intentselectoritem_checkbox) + + val item = getSubList(entries[groupPosition])[childPosition] + txt.text = item.displayName + txtActivityName.text = item.activityName + + if ((context as IntentSelector).allowMultiSelect && intentType == IntentApplicationEntry.IntentType.Main) { + checkbox.visibility = View.VISIBLE + val testIntent = Intent(Intent.ACTION_MAIN) + testIntent.setClassName(item.packageName, item.activityName) + checkbox.isChecked = context.isSelected(testIntent) + } else { + checkbox.visibility = View.GONE + } - txt.text = getSubList(entries[groupPosition])[childPosition].displayName - txtActivityName.text = getSubList(entries[groupPosition])[childPosition].activityName return effectiveConvertView } @@ -387,6 +492,8 @@ class IntentSelector : Activity() { var EXTRA_SHORTCUT_TEXT = "de.devmil.common.extras.SHORTCUT_TEXT" var EXTRA_STRING_SHORTCUTS = "de.devmil.common.extras.STRING_SHORTCUTS" var EXTRA_STRING_ACTIVITIES = "de.devmil.common.extras.STRING_ACTIVITIES" + var EXTRA_ALLOW_MULTI_SELECT = "de.devmil.common.extras.ALLOW_MULTI_SELECT" + var EXTRA_RESULT_INTENTS = "de.devmil.common.extras.RESULT_INTENTS" private fun getSubList(entry: IntentApplicationEntry, intentType: IntentApplicationEntry.IntentType): List { when (intentType) { diff --git a/app/src/main/res/drawable/ic_check.xml b/app/src/main/res/drawable/ic_check.xml new file mode 100644 index 0000000..4c630a8 --- /dev/null +++ b/app/src/main/res/drawable/ic_check.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/common__intentselectorgroup.xml b/app/src/main/res/layout/common__intentselectorgroup.xml index 06e96c9..05dbff2 100644 --- a/app/src/main/res/layout/common__intentselectorgroup.xml +++ b/app/src/main/res/layout/common__intentselectorgroup.xml @@ -12,7 +12,8 @@ android:layout_width="fill_parent" android:layout_height="45dip" android:text="@string/common__intentselectorgroup_text_default" - android:gravity="center_vertical|end" + android:gravity="center_vertical|start" + android:layout_marginStart="16dp" android:paddingStart="5dip" android:paddingEnd="5dip" android:textStyle="bold" diff --git a/app/src/main/res/layout/common__intentselectoritem.xml b/app/src/main/res/layout/common__intentselectoritem.xml index 9528db8..b1f67ba 100644 --- a/app/src/main/res/layout/common__intentselectoritem.xml +++ b/app/src/main/res/layout/common__intentselectoritem.xml @@ -1,21 +1,53 @@ - - - - - - + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical" + android:padding="8dp" + android:minHeight="?android:attr/listPreferredItemHeight"> + + + + + + + + + + + diff --git a/app/src/main/res/layout/common__intentselectorview.xml b/app/src/main/res/layout/common__intentselectorview.xml index 67aedf8..3cd3759 100644 --- a/app/src/main/res/layout/common__intentselectorview.xml +++ b/app/src/main/res/layout/common__intentselectorview.xml @@ -6,7 +6,7 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/toolbar_appcompat.xml b/app/src/main/res/layout/toolbar_appcompat.xml new file mode 100644 index 0000000..6a8ad66 --- /dev/null +++ b/app/src/main/res/layout/toolbar_appcompat.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/menu/menu_intent_selector.xml b/app/src/main/res/menu/menu_intent_selector.xml new file mode 100644 index 0000000..5ecde58 --- /dev/null +++ b/app/src/main/res/menu/menu_intent_selector.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 731c38b..7a93c23 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -93,4 +93,14 @@ Show activation hint Activation hint is shown Activation hint is not shown + Backup & Restore + Export Configuration + Import Configuration + Export successful + Export failed + Import successful + Import failed + Overwrite Data? + This will overwrite your current configuration. Are you sure? + Select App (%d) diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index e1924bd..0047714 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -8,4 +8,14 @@ @color/theme_primary_light + + diff --git a/build.gradle b/build.gradle index 9a1e5ef..df9881f 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ buildscript { google() } dependencies { - classpath 'com.android.tools.build:gradle:8.10.1' + classpath 'com.android.tools.build:gradle:8.13.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e9a6ca5..96bb16c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip From 82d80e02eeba11a19fba94649debdbccf45f6b78 Mon Sep 17 00:00:00 2001 From: Michael Lamers Date: Mon, 29 Dec 2025 09:26:50 +0100 Subject: [PATCH 2/5] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../main/java/de/devmil/paperlaunch/storage/DataImporter.kt | 2 +- app/src/main/res/layout/common__intentselectoritem.xml | 3 ++- app/src/main/res/layout/common__intentselectorview.xml | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/de/devmil/paperlaunch/storage/DataImporter.kt b/app/src/main/java/de/devmil/paperlaunch/storage/DataImporter.kt index 3415d79..5f1ee53 100644 --- a/app/src/main/java/de/devmil/paperlaunch/storage/DataImporter.kt +++ b/app/src/main/java/de/devmil/paperlaunch/storage/DataImporter.kt @@ -60,7 +60,7 @@ class DataImporter(private val context: Context) { } else if (entry.type == "launch") { val launch = transactionContext.createLaunch(parentFolderId, orderIndex) launch.dto.name = entry.name - launch.dto.launchIntent = IntentSerializer.deserialize(entry.intentUri) + launch.dto.launchIntent = entry.intentUri?.let { IntentSerializer.deserialize(it) } if (entry.icon != null) { launch.dto.icon = decodeIcon(entry.icon) } diff --git a/app/src/main/res/layout/common__intentselectoritem.xml b/app/src/main/res/layout/common__intentselectoritem.xml index b1f67ba..2b87ffd 100644 --- a/app/src/main/res/layout/common__intentselectoritem.xml +++ b/app/src/main/res/layout/common__intentselectoritem.xml @@ -15,7 +15,8 @@ android:focusable="false" android:clickable="false" android:layout_marginEnd="12dp" - android:gravity="center"/> + android:gravity="center" + android:contentDescription="Select this item"/> + android:contentDescription="Confirm selection" /> \ No newline at end of file From f594ee94882780f9800901cb3ca1acd762dbdaf8 Mon Sep 17 00:00:00 2001 From: Michael Lamers Date: Mon, 29 Dec 2025 09:33:20 +0100 Subject: [PATCH 3/5] review remark: lowercase performance --- .../paperlaunch/view/utils/IntentApplicationEntry.kt | 11 +++++++++-- .../devmil/paperlaunch/view/utils/IntentSelector.kt | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentApplicationEntry.kt b/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentApplicationEntry.kt index 550b147..f4ac294 100644 --- a/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentApplicationEntry.kt +++ b/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentApplicationEntry.kt @@ -22,7 +22,10 @@ import android.content.pm.PackageManager.NameNotFoundException import android.content.pm.ResolveInfo import android.graphics.drawable.Drawable import java.lang.ref.WeakReference -import java.util.* +import java.util.ArrayList +import java.util.Collections +import java.util.Comparator +import java.util.Locale class IntentApplicationEntry @Throws(NameNotFoundException::class) constructor(context: Context, packageName: String) : Comparable { @@ -35,6 +38,7 @@ constructor(context: Context, packageName: String) : Comparable() @@ -43,8 +47,8 @@ constructor(context: Context, packageName: String) : Comparable? = null init { - name = context.packageManager.getApplicationLabel(appInfo) + nameLowercase = name.toString().lowercase(Locale.getDefault()) } fun addResolveInfo(info: ResolveInfo, intentType: IntentType) { @@ -164,6 +168,9 @@ constructor(context: Context, packageName: String) : Comparable = WeakReference(context) diff --git a/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentSelector.kt b/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentSelector.kt index f891124..9cb244c 100644 --- a/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentSelector.kt +++ b/app/src/main/java/de/devmil/paperlaunch/view/utils/IntentSelector.kt @@ -385,11 +385,11 @@ class IntentSelector : AppCompatActivity(), SearchView.OnQueryTextListener { val q = query.lowercase(Locale.getDefault()) for (entry in originalEntries) { var matches = false - if (entry.name.toString().lowercase(Locale.getDefault()).contains(q)) { + if (entry.nameLowercase.contains(q)) { matches = true } else { val subList = getSubList(entry) - if (subList.any { it.displayName.lowercase(Locale.getDefault()).contains(q) }) { + if (subList.any { it.displayNameLowercase.contains(q) }) { matches = true } } From 6717861d10db8dfc8cae86436df86e1a4cdd87b1 Mon Sep 17 00:00:00 2001 From: Michael Lamers Date: Mon, 29 Dec 2025 09:33:28 +0100 Subject: [PATCH 4/5] review remark: threading --- .../view/fragments/SettingsFragment.kt | 49 ++++++++++++------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/de/devmil/paperlaunch/view/fragments/SettingsFragment.kt b/app/src/main/java/de/devmil/paperlaunch/view/fragments/SettingsFragment.kt index c5953c9..3f3ece7 100644 --- a/app/src/main/java/de/devmil/paperlaunch/view/fragments/SettingsFragment.kt +++ b/app/src/main/java/de/devmil/paperlaunch/view/fragments/SettingsFragment.kt @@ -43,6 +43,8 @@ class SettingsFragment : PreferenceFragment() { private var userSettings: UserSettings? = null private var activationParametersChangedListener: (() -> Unit)? = null + private val executor = java.util.concurrent.Executors.newSingleThreadExecutor() + @Deprecated("Deprecated in Java") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -59,48 +61,61 @@ class SettingsFragment : PreferenceFragment() { addBackupSettings(context, screen) } + override fun onDestroy() { + super.onDestroy() + executor.shutdown() + } + @Deprecated("Deprecated in Java") override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { super.onActivityResult(requestCode, resultCode, data) if (resultCode == Activity.RESULT_OK && data != null && data.data != null) { val uri = data.data!! if (requestCode == REQUEST_CODE_EXPORT) { - Thread { + executor.execute { + val context = activity + if (context == null || context.isFinishing) { + return@execute + } try { - val json = DataExporter(activity).exportToJson() - activity.contentResolver.openOutputStream(uri)?.use { output -> + val json = DataExporter(context).exportToJson() + context.contentResolver.openOutputStream(uri)?.use { output -> output.write(json.toByteArray()) } - activity.runOnUiThread { - Toast.makeText(activity, R.string.fragment_settings_backup_export_success, Toast.LENGTH_SHORT).show() + context.runOnUiThread { + Toast.makeText(context, R.string.fragment_settings_backup_export_success, Toast.LENGTH_SHORT).show() } } catch (e: Exception) { e.printStackTrace() - activity.runOnUiThread { - Toast.makeText(activity, R.string.fragment_settings_backup_export_error, Toast.LENGTH_SHORT).show() + context.runOnUiThread { + Toast.makeText(context, R.string.fragment_settings_backup_export_error, Toast.LENGTH_SHORT).show() } } - }.start() + } } else if (requestCode == REQUEST_CODE_IMPORT) { - Thread { + executor.execute { + val context = activity + if (context == null || context.isFinishing) { + return@execute + } try { - val json = activity.contentResolver.openInputStream(uri)?.use { input -> + val json = context.contentResolver.openInputStream(uri)?.use { input -> input.bufferedReader().use { it.readText() } } if (json != null) { - DataImporter(activity).importFromJson(json) - activity.runOnUiThread { - Toast.makeText(activity, R.string.fragment_settings_backup_import_success, Toast.LENGTH_SHORT).show() - LauncherOverlayService.notifyDataChanged(activity) + DataImporter(context).importFromJson(json) + context.runOnUiThread { + Toast.makeText(context, R.string.fragment_settings_backup_import_success, Toast.LENGTH_SHORT).show() + LauncherOverlayService.notifyDataChanged(context) } } } catch (e: Exception) { e.printStackTrace() - activity.runOnUiThread { - Toast.makeText(activity, R.string.fragment_settings_backup_import_error, Toast.LENGTH_SHORT).show() + context.runOnUiThread { + Toast.makeText(context, R.string.fragment_settings_backup_import_error, Toast.LENGTH_SHORT).show() } } - }.start() + } } } } From 01509f764a9c8a0a19abb5f9cff98c14abfff461 Mon Sep 17 00:00:00 2001 From: Michael Lamers Date: Mon, 29 Dec 2025 09:47:28 +0100 Subject: [PATCH 5/5] version bump + changelog --- CHANGELOG.md | 5 +++++ app/build.gradle | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3272c25 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## 2.1.0 + +- Add settings export/import +- Improve Intent selector (multi-select, search) +- Bump Gradle diff --git a/app/build.gradle b/app/build.gradle index 6975501..0bad730 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,8 +7,8 @@ android { minSdkVersion 21 compileSdkVersion 35 targetSdkVersion 35 - versionCode 19 - versionName "2.0.0" + versionCode 20 + versionName "2.1.0" archivesBaseName = "paperlaunch-v${defaultConfig.versionName}-${buildTime()}" testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" }