From ef88f9510bf351cbdc964352b4140198f83c9d01 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 15 Aug 2026 22:03:30 -0700 Subject: [PATCH 01/23] ADFA-4934: Introduce shared PLUGIN_ARCHIVE_EXTENSION constant Consolidates the ".cgp" literal duplicated across ~7 sites into a single constant, mirroring the existing TEMPLATE_ARCHIVE_EXTENSION. Prep work for the external file-install feature, which needs a canonical way to recognize .cgp files. Co-Authored-By: Claude Sonnet 5 --- .../actions/file/InstallFileAction.kt | 80 +++-- .../activities/PluginManagerActivity.kt | 3 +- .../handlers/FileTreeActionHandler.kt | 305 +++++++++--------- .../repositories/PluginRepositoryImpl.kt | 3 +- .../itsaky/androidide/ui/CodeEditorView.kt | 3 +- .../androidide/viewmodel/BuildViewModel.kt | 3 +- .../main/java/org/adfa/constants/constants.kt | 3 + .../plugins/manager/core/PluginManager.kt | 9 +- 8 files changed, 218 insertions(+), 191 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/actions/file/InstallFileAction.kt b/app/src/main/java/com/itsaky/androidide/actions/file/InstallFileAction.kt index eef6953bf5..52e7f95f20 100644 --- a/app/src/main/java/com/itsaky/androidide/actions/file/InstallFileAction.kt +++ b/app/src/main/java/com/itsaky/androidide/actions/file/InstallFileAction.kt @@ -28,43 +28,57 @@ import com.itsaky.androidide.utils.DialogUtils import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess import kotlinx.coroutines.launch +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.koin.core.context.GlobalContext -class InstallFileAction(context: Context, override val order: Int) : FileTabAction() { +class InstallFileAction( + context: Context, + override val order: Int, +) : FileTabAction() { + override val id: String = "ide.editor.fileTab.install" - override val id: String = "ide.editor.fileTab.install" + init { + label = context.getString(R.string.action_install) + } - init { - label = context.getString(R.string.action_install) - } + override fun prepare(data: ActionData) { + super.prepare(data) + if (!visible) return + val activity = + data.getActivity() ?: run { + markInvisible() + return + } + val currentFile = activity.editorViewModel.getCurrentFile() + visible = currentFile?.extension?.lowercase() in setOf("apk", PLUGIN_ARCHIVE_EXTENSION) + enabled = visible + } - override fun prepare(data: ActionData) { - super.prepare(data) - if (!visible) return - val activity = data.getActivity() ?: run { markInvisible(); return } - val currentFile = activity.editorViewModel.getCurrentFile() - visible = currentFile?.extension?.lowercase() in setOf("apk", "cgp") - enabled = visible - } + override fun EditorHandlerActivity.doAction(data: ActionData): Boolean { + val file = editorViewModel.getCurrentFile() ?: return false + when (file.extension.lowercase()) { + "apk" -> { + apkInstallationViewModel.installApk( + context = this, + apk = file, + launchInDebugMode = false, + ) + } - override fun EditorHandlerActivity.doAction(data: ActionData): Boolean { - val file = editorViewModel.getCurrentFile() ?: return false - when (file.extension.lowercase()) { - "apk" -> apkInstallationViewModel.installApk( - context = this, apk = file, launchInDebugMode = false - ) - "cgp" -> lifecycleScope.launch { - val repo = GlobalContext.get().get() - repo.installPluginFromFile(file) - .onSuccess { - flashSuccess(getString(R.string.msg_plugin_installed_restart)) - DialogUtils.showRestartPrompt(this@doAction) - } - .onFailure { e -> - flashError(getString(R.string.msg_plugin_install_failed, e.message)) - } - } - } - return true - } + PLUGIN_ARCHIVE_EXTENSION -> { + lifecycleScope.launch { + val repo = GlobalContext.get().get() + repo + .installPluginFromFile(file) + .onSuccess { + flashSuccess(getString(R.string.msg_plugin_installed_restart)) + DialogUtils.showRestartPrompt(this@doAction) + }.onFailure { e -> + flashError(getString(R.string.msg_plugin_install_failed, e.message)) + } + } + } + } + return true + } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index a3129fbffb..cddef1830a 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -40,12 +40,13 @@ import com.itsaky.androidide.utils.getFileName import com.itsaky.androidide.utils.showOnUiThread import com.itsaky.androidide.viewmodels.PluginManagerViewModel import kotlinx.coroutines.launch +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.koin.androidx.viewmodel.ext.android.viewModel class PluginManagerActivity : EdgeToEdgeIDEActivity() { companion object { private const val TAG = "PluginManagerActivity" - private const val PLUGIN_EXTENSION = ".cgp" + private const val PLUGIN_EXTENSION = ".$PLUGIN_ARCHIVE_EXTENSION" } @Suppress("ktlint:standard:backing-property-naming") diff --git a/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt b/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt index 82309f0c9f..8e25862d9f 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt @@ -26,6 +26,7 @@ import com.itsaky.androidide.actions.ActionMenu import com.itsaky.androidide.actions.ActionsRegistry import com.itsaky.androidide.actions.internal.DefaultActionsRegistry import com.itsaky.androidide.activities.editor.EditorHandlerActivity +import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.eventbus.events.filetree.FileClickEvent import com.itsaky.androidide.eventbus.events.filetree.FileLongClickEvent import com.itsaky.androidide.events.CollapseTreeNodeRequestEvent @@ -34,12 +35,12 @@ import com.itsaky.androidide.events.FileContextMenuItemClickEvent import com.itsaky.androidide.events.FileContextMenuItemLongClickEvent import com.itsaky.androidide.fragments.sheets.OptionsListFragment import com.itsaky.androidide.idetooltips.TooltipManager -import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.models.SheetOption import com.itsaky.androidide.plugins.extensions.FileTabMenuItem import com.itsaky.androidide.utils.flashError import com.unnamed.b.atv.model.TreeNode import kotlinx.coroutines.launch +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode.MAIN @@ -52,153 +53,157 @@ import java.io.File */ @Suppress("unused") class FileTreeActionHandler : BaseEventHandler() { - - private var lastHeld: TreeNode? = null - - companion object { - - const val TAG_FILE_OPTIONS_FRAGMENT = "file_options_fragment" - const val MB_10: Long = 10 * 1024 * 1024 - } - - @Subscribe(threadMode = MAIN) - fun onFileClicked(event: FileClickEvent) { - if (!checkIsEditorActivity(event)) { - logCannotHandle(event) - return - } - - if (event.file.isDirectory) { - return - } - - val context = event[Context::class.java]!! as EditorHandlerActivity - context.binding.editorDrawerLayout.closeDrawer(GravityCompat.START) - - val isArchive = event.file.extension.lowercase() in setOf("apk", "cgp", "zip") - if (!isArchive && MB_10 < event.file.length()) { - flashError("File is too big!") - log.warn( - "Cannot open {} as it is too big. File size: {} bytes", event.file, event.file.length()) - return - } - - context.lifecycleScope.launch { - context.openFile(event.file) - } - } - - @Subscribe(threadMode = MAIN) - fun onFileLongClicked(event: FileLongClickEvent) { - if (!checkIsEditorActivity(event)) { - logCannotHandle(event) - return - } - - this.lastHeld = event[TreeNode::class.java] - val context = event[Context::class.java]!! as EditorHandlerActivity - createFileOptionsFragment(context, event.file) - .show(context.supportFragmentManager, TAG_FILE_OPTIONS_FRAGMENT) - } - - private fun createFileOptionsFragment( - context: EditorHandlerActivity, - file: File - ): OptionsListFragment { - val fragment = OptionsListFragment() - val registry = ActionsRegistry.getInstance() - val actions = registry.getActions(EDITOR_FILE_TREE) - val data = ActionData.create(context) - data.apply { - put(File::class.java, file) - put(TreeNode::class.java, lastHeld) - } - - for (action in actions.values) { - - check(action !is ActionMenu) { "File tree actions do not support action menus" } - - action.prepare(data) - if (!action.enabled || !action.visible) { - continue - } - - fragment.addOption( - SheetOption(action.id, action.icon, action.label, file).apply { this.extra = data } - ) - } - - IDEApplication.getPluginManager() - ?.getFileTabMenuItems(file) - ?.filter { it.isEnabled && it.isVisible } - ?.forEach { item -> - fragment.addOption(SheetOption("plugin.file.${item.id}", null, item.title, item)) - } - - return fragment - } - - @Subscribe(threadMode = MAIN) - internal fun onFileOptionClicked(event: FileContextMenuItemClickEvent) { - val option = event.option - if (option.extra is FileTabMenuItem) { - try { (option.extra as FileTabMenuItem).action() } catch (e: Exception) { log.error("Plugin file menu action failed", e) } - return - } - if (option.extra !is ActionData) { - return - } - - val data = option.extra!! as ActionData - val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry - val action = registry.findAction(EDITOR_FILE_TREE, option.id) - - checkNotNull(action) { - "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'" - } - - registry.executeAction(action, data) - } - - @Subscribe(threadMode = MAIN) - internal fun onFileOptionLongClicked(event: FileContextMenuItemLongClickEvent) { - val option = event.option - val actionData = option.extra - if (actionData !is ActionData) { - return - } - - val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry - val action = registry.findAction(EDITOR_FILE_TREE, option.id) - - checkNotNull(action) { - "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'" - } - val tag = action.retrieveTooltipTag(actionData.get(File::class.java)?.isDirectory == true) - tag.isNotEmpty() || return - val activity = event[Context::class.java] as? EditorHandlerActivity - activity?.let { act -> - TooltipManager.showIdeCategoryTooltip( - context = act, - anchorView = act.window.decorView, - tag = tag, - ) - } - } - - private fun requestExpandHeldNode() { - requestExpandNode(lastHeld!!) - } - - private fun requestCollapseHeldNode() { - requestCollapseNode(lastHeld!!, true) - } - - private fun requestExpandNode(node: TreeNode) { - EventBus.getDefault().post(ExpandTreeNodeRequestEvent(node)) - } - - private fun requestCollapseNode(node: TreeNode, includeSubnodes: Boolean) { - EventBus.getDefault().post(CollapseTreeNodeRequestEvent(node, includeSubnodes)) - } + private var lastHeld: TreeNode? = null + + companion object { + const val TAG_FILE_OPTIONS_FRAGMENT = "file_options_fragment" + const val MB_10: Long = 10 * 1024 * 1024 + } + + @Subscribe(threadMode = MAIN) + fun onFileClicked(event: FileClickEvent) { + if (!checkIsEditorActivity(event)) { + logCannotHandle(event) + return + } + + if (event.file.isDirectory) { + return + } + + val context = event[Context::class.java]!! as EditorHandlerActivity + context.binding.editorDrawerLayout.closeDrawer(GravityCompat.START) + + val isArchive = event.file.extension.lowercase() in setOf("apk", PLUGIN_ARCHIVE_EXTENSION, "zip") + if (!isArchive && MB_10 < event.file.length()) { + flashError("File is too big!") + log.warn("Cannot open {} as it is too big. File size: {} bytes", event.file, event.file.length()) + return + } + + context.lifecycleScope.launch { + context.openFile(event.file) + } + } + + @Subscribe(threadMode = MAIN) + fun onFileLongClicked(event: FileLongClickEvent) { + if (!checkIsEditorActivity(event)) { + logCannotHandle(event) + return + } + + this.lastHeld = event[TreeNode::class.java] + val context = event[Context::class.java]!! as EditorHandlerActivity + createFileOptionsFragment(context, event.file) + .show(context.supportFragmentManager, TAG_FILE_OPTIONS_FRAGMENT) + } + + private fun createFileOptionsFragment( + context: EditorHandlerActivity, + file: File, + ): OptionsListFragment { + val fragment = OptionsListFragment() + val registry = ActionsRegistry.getInstance() + val actions = registry.getActions(EDITOR_FILE_TREE) + val data = ActionData.create(context) + data.apply { + put(File::class.java, file) + put(TreeNode::class.java, lastHeld) + } + + for (action in actions.values) { + check(action !is ActionMenu) { "File tree actions do not support action menus" } + + action.prepare(data) + if (!action.enabled || !action.visible) { + continue + } + + fragment.addOption( + SheetOption(action.id, action.icon, action.label, file).apply { this.extra = data }, + ) + } + + IDEApplication + .getPluginManager() + ?.getFileTabMenuItems(file) + ?.filter { it.isEnabled && it.isVisible } + ?.forEach { item -> + fragment.addOption(SheetOption("plugin.file.${item.id}", null, item.title, item)) + } + + return fragment + } + + @Subscribe(threadMode = MAIN) + internal fun onFileOptionClicked(event: FileContextMenuItemClickEvent) { + val option = event.option + if (option.extra is FileTabMenuItem) { + try { + (option.extra as FileTabMenuItem).action() + } catch (e: Exception) { + log.error("Plugin file menu action failed", e) + } + return + } + if (option.extra !is ActionData) { + return + } + + val data = option.extra!! as ActionData + val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry + val action = registry.findAction(EDITOR_FILE_TREE, option.id) + + checkNotNull(action) { + "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'" + } + + registry.executeAction(action, data) + } + + @Subscribe(threadMode = MAIN) + internal fun onFileOptionLongClicked(event: FileContextMenuItemLongClickEvent) { + val option = event.option + val actionData = option.extra + if (actionData !is ActionData) { + return + } + + val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry + val action = registry.findAction(EDITOR_FILE_TREE, option.id) + + checkNotNull(action) { + "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'" + } + val tag = action.retrieveTooltipTag(actionData.get(File::class.java)?.isDirectory == true) + tag.isNotEmpty() || return + val activity = event[Context::class.java] as? EditorHandlerActivity + activity?.let { act -> + TooltipManager.showIdeCategoryTooltip( + context = act, + anchorView = act.window.decorView, + tag = tag, + ) + } + } + + private fun requestExpandHeldNode() { + requestExpandNode(lastHeld!!) + } + + private fun requestCollapseHeldNode() { + requestCollapseNode(lastHeld!!, true) + } + + private fun requestExpandNode(node: TreeNode) { + EventBus.getDefault().post(ExpandTreeNodeRequestEvent(node)) + } + + private fun requestCollapseNode( + node: TreeNode, + includeSubnodes: Boolean, + ) { + EventBus.getDefault().post(CollapseTreeNodeRequestEvent(node, includeSubnodes)) + } } diff --git a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt index 565d989c2f..701acda5c9 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt @@ -7,6 +7,7 @@ import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.plugins.manager.loaders.toPluginMetadata import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import java.io.File /** @@ -141,7 +142,7 @@ class PluginRepositoryImpl( Log.w(TAG, "Error uninstalling existing plugin: ${e.message}") } - val fileExtension = if (pluginFile.name.endsWith(".cgp")) ".cgp" else ".apk" + val fileExtension = if (pluginFile.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION")) ".$PLUGIN_ARCHIVE_EXTENSION" else ".apk" val finalFileName = "${pluginId}$fileExtension" if (!pluginsDir.exists()) { diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt index 5a219cfedd..56a886e4eb 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -76,6 +76,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -88,7 +89,7 @@ import kotlin.math.abs private const val MIN_FONT_SIZE = EditorPreferences.FONT_SIZE_MIN private const val DEFAULT_FONT_SIZE = EditorPreferences.FONT_SIZE_DEFAULT private const val MAX_FONT_SIZE = EditorPreferences.FONT_SIZE_MAX -private val ARCHIVE_EXTENSIONS = setOf("apk", "cgp", "zip") +private val ARCHIVE_EXTENSIONS = setOf("apk", PLUGIN_ARCHIVE_EXTENSION, "zip") /** * A view that handles opened code editor. diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index 790276a4de..5c220f86c8 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.future.await import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.slf4j.LoggerFactory import java.io.File import kotlin.coroutines.cancellation.CancellationException @@ -150,7 +151,7 @@ class BuildViewModel : ViewModel() { val isDebug = variant.name.contains("debug", ignoreCase = true) return pluginDir - .listFiles { file -> file.extension.equals("cgp", ignoreCase = true) } + .listFiles { file -> file.extension.equals(PLUGIN_ARCHIVE_EXTENSION, ignoreCase = true) } ?.filter { it.name.contains("-debug") == isDebug } ?.maxByOrNull { it.lastModified() } } diff --git a/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt b/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt index d7bc7f5694..4e67e29197 100644 --- a/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt +++ b/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt @@ -88,3 +88,6 @@ const val GRADLE_API_NAME_JAR_BR = "${GRADLE_API_NAME_JAR}.br" const val TEMPLATE_ARCHIVE_EXTENSION = "cgt" const val TEMPLATE_CORE_ARCHIVE = "core.$TEMPLATE_ARCHIVE_EXTENSION" const val TEMPLATE_CORE_ARCHIVE_BR = "${TEMPLATE_CORE_ARCHIVE}.br" + +// Plugin archive +const val PLUGIN_ARCHIVE_EXTENSION = "cgp" diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index ba895ebd7c..50d401dfd6 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -84,6 +84,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import java.io.File import java.util.concurrent.ConcurrentHashMap @@ -385,7 +386,7 @@ class PluginManager private constructor( val pluginFiles = pluginsDir.listFiles { file -> - file.isFile && file.name.endsWith(".cgp", ignoreCase = true) + file.isFile && file.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true) } ?: return@withContext logger.info("Found ${pluginFiles.size} plugin files") @@ -488,7 +489,7 @@ class PluginManager private constructor( return Result.failure(IllegalArgumentException(error)) } - if (!pluginFile.name.endsWith(".cgp", ignoreCase = true)) { + if (!pluginFile.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true)) { val error = "Only CGP plugins are supported. File: ${pluginFile.name}" logger.error(error) return Result.failure(IllegalArgumentException(error)) @@ -840,7 +841,7 @@ class PluginManager private constructor( incomingFile: File, existingPluginId: String, ): Boolean { - val existingFile = File(pluginsDir, "$existingPluginId.cgp") + val existingFile = File(pluginsDir, "$existingPluginId.$PLUGIN_ARCHIVE_EXTENSION") val incomingSig = PluginLoader(context, incomingFile).getSignatureHash() val existingSig = PluginLoader(context, existingFile).getSignatureHash() if (incomingSig == null || existingSig == null) { @@ -870,7 +871,7 @@ class PluginManager private constructor( // Find and delete the plugin file (CGP) val pluginFiles = pluginsDir.listFiles { file -> - file.isFile && file.name.endsWith(".cgp", ignoreCase = true) + file.isFile && file.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true) } if (pluginFiles == null || pluginFiles.isEmpty()) { From fe29e052f755ff4c93b65cdd1eaf6c3391e84870 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 15 Aug 2026 22:30:46 -0700 Subject: [PATCH 02/23] ADFA-4934: Install a .cgp or .cgt file opened from outside the app Adds a VIEW intent-filter (ExternalFileInstallActivity) so opening a .cgp/.cgt attachment (e.g. from email) prompts to install it, instead of doing nothing. .cgp files are copied to a temp file and forwarded into PluginManagerActivity, reusing its existing install/conflict/signature-check flow verbatim rather than duplicating it. .cgt files get a new TemplateCollectionRepository, since no import/conflict backend existed for template collections before now: it validates the archive via the existing ZipTemplateReader, and on a filename collision offers overwrite / rename-and-install / ignore (the ticket's requested UX), using the archive's filename as its identity since templates.json has no collection-level name field. Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 32 +++ .../activities/ExternalFileInstallActivity.kt | 163 +++++++++++++++ .../activities/PluginManagerActivity.kt | 10 + .../com/itsaky/androidide/di/PluginModule.kt | 51 +++-- .../androidide/provider/IDEFileProvider.kt | 20 +- .../TemplateCollectionRepository.kt | 38 ++++ .../TemplateCollectionRepositoryImpl.kt | 83 ++++++++ .../ui/models/ExternalFileInstallUiModels.kt | 47 +++++ .../ExternalFileInstallViewModel.kt | 189 ++++++++++++++++++ .../TemplateCollectionRepositoryImplTest.kt | 136 +++++++++++++ .../ExternalFileInstallViewModelTest.kt | 176 ++++++++++++++++ resources/src/main/res/values/strings.xml | 14 ++ 12 files changed, 941 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt create mode 100644 app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt create mode 100644 app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt create mode 100644 app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt create mode 100644 app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index cf216f8b6c..779a34f591 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -116,6 +116,38 @@ + + + + + + + + + + + + + + + + + + + + + handleUiEffect(effect) } + } + } + } + + private fun handleUiEffect(effect: ExternalFileInstallUiEffect) { + when (effect) { + is ExternalFileInstallUiEffect.ForwardToPluginManager -> { + startActivity( + Intent(this, PluginManagerActivity::class.java) + .putExtra(PluginManagerActivity.EXTRA_PENDING_INSTALL_URI, effect.uri), + ) + finish() + } + + is ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation -> { + showInstallConfirmation(effect) + } + + is ExternalFileInstallUiEffect.ShowTemplateNameConflict -> { + showNameConflict(effect) + } + + is ExternalFileInstallUiEffect.ShowError -> { + flashError(getString(effect.messageResId, *effect.formatArgs.toTypedArray())) + } + + is ExternalFileInstallUiEffect.ShowSuccess -> { + flashSuccess(getString(effect.messageResId)) + } + + is ExternalFileInstallUiEffect.Finish -> { + finish() + } + } + } + + private fun showInstallConfirmation(effect: ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.title_install_template_collection) + .setMessage( + getString( + R.string.msg_template_install_confirm, + effect.suggestedBaseName, + effect.info.templateNames.joinToString(", "), + ), + ).setPositiveButton(R.string.btn_install) { _, _ -> + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + tempFile = effect.tempFile, + targetBaseName = effect.suggestedBaseName, + overwrite = false, + ), + ) + }.setNegativeButton(android.R.string.cancel) { _, _ -> + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) + }.setOnCancelListener { + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) + }.show() + } + + private fun showNameConflict(effect: ExternalFileInstallUiEffect.ShowTemplateNameConflict) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.title_template_already_installed) + .setMessage(getString(R.string.msg_template_name_conflict, effect.existingName)) + .setPositiveButton(R.string.btn_rename_and_install) { _, _ -> + showRenameDialog(effect) + }.setNeutralButton(R.string.btn_overwrite) { _, _ -> + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + tempFile = effect.tempFile, + targetBaseName = effect.existingName, + overwrite = true, + ), + ) + }.setNegativeButton(android.R.string.cancel) { _, _ -> + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) + }.setOnCancelListener { + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) + }.show() + } + + private fun showRenameDialog(effect: ExternalFileInstallUiEffect.ShowTemplateNameConflict) { + val binding = RenameProjectTextinputBinding.inflate(layoutInflater) + binding.textinputLayout.hint = getString(R.string.hint_new_template_collection_name) + + val dialog = + MaterialAlertDialogBuilder(this) + .setTitle(R.string.btn_rename_and_install) + .setView(binding.root) + .setPositiveButton(R.string.btn_install) { _, _ -> + val newName = viewModel.sanitizeBaseName(binding.textinputEdittext.text.toString()) + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + tempFile = effect.tempFile, + targetBaseName = newName, + overwrite = false, + ), + ) + }.setNegativeButton(android.R.string.cancel) { _, _ -> + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) + }.setOnCancelListener { + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) + }.show() + + lifecycleScope.launch { + val suggested = viewModel.suggestUniqueBaseName(effect.existingName) + if (!dialog.isShowing) return@launch + binding.textinputEdittext.setText(suggested) + binding.textinputEdittext.selectAll() + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index cddef1830a..5c3236f119 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -13,6 +13,7 @@ import android.view.MenuItem import android.view.View import android.widget.CheckBox import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.IntentCompat import androidx.core.graphics.Insets import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -47,6 +48,9 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { companion object { private const val TAG = "PluginManagerActivity" private const val PLUGIN_EXTENSION = ".$PLUGIN_ARCHIVE_EXTENSION" + + /** A `.cgp` [Uri] forwarded from [com.itsaky.androidide.activities.ExternalFileInstallActivity]. */ + const val EXTRA_PENDING_INSTALL_URI = "pending_install_uri" } @Suppress("ktlint:standard:backing-property-naming") @@ -104,6 +108,12 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { setupTooltipLongPress() setupFeedbackButton() observeViewModel() + + if (savedInstanceState == null) { + IntentCompat + .getParcelableExtra(intent, EXTRA_PENDING_INSTALL_URI, Uri::class.java) + ?.let { showInstallConfirmation(it) } + } } catch (e: Exception) { // Log the error and finish the activity if something goes wrong e.printStackTrace() diff --git a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt index 0152cc285b..fc3a7b0e0a 100644 --- a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt @@ -3,6 +3,9 @@ package com.itsaky.androidide.di import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.repositories.PluginRepository import com.itsaky.androidide.repositories.PluginRepositoryImpl +import com.itsaky.androidide.repositories.TemplateCollectionRepository +import com.itsaky.androidide.repositories.TemplateCollectionRepositoryImpl +import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel import com.itsaky.androidide.viewmodels.PluginManagerViewModel import org.koin.android.ext.koin.androidContext import org.koin.androidx.viewmodel.dsl.viewModel @@ -12,22 +15,36 @@ import java.io.File /** * Koin module for plugin-related dependencies */ -val pluginModule = module { +val pluginModule = + module { - // Repository - single { - PluginRepositoryImpl( - pluginManagerProvider = { IDEApplication.getPluginManager() }, - pluginsDir = File(androidContext().filesDir, "plugins") - ) - } + // Repository + single { + PluginRepositoryImpl( + pluginManagerProvider = { IDEApplication.getPluginManager() }, + pluginsDir = File(androidContext().filesDir, "plugins"), + ) + } - // ViewModel - viewModel { - PluginManagerViewModel( - pluginRepository = get(), - contentResolver = androidContext().contentResolver, - filesDir = androidContext().filesDir - ) - } -} \ No newline at end of file + single { + TemplateCollectionRepositoryImpl() + } + + // ViewModel + viewModel { + PluginManagerViewModel( + pluginRepository = get(), + contentResolver = androidContext().contentResolver, + filesDir = androidContext().filesDir, + ) + } + + viewModel { + ExternalFileInstallViewModel( + pluginRepository = get(), + templateCollectionRepository = get(), + contentResolver = androidContext().contentResolver, + filesDir = androidContext().filesDir, + ) + } + } diff --git a/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt b/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt index e355b914d7..e7deea1e73 100644 --- a/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt @@ -17,11 +17,29 @@ package com.itsaky.androidide.provider +import android.content.Context +import android.net.Uri import androidx.core.content.FileProvider +import java.io.File /** * AndroidIDE file provider. * * @author Akash Yadav */ -class IDEFileProvider : FileProvider() +class IDEFileProvider : FileProvider() { + companion object { + private const val AUTHORITY_SUFFIX = ".providers.fileprovider" + + /** + * Mint a `content://` [Uri] for [file] via this provider, so it can be shared with + * another component in this app without relying on a Uri permission grant to have + * carried over from wherever [file]'s bytes originally came from. + */ + @JvmStatic + fun getUriForFile( + context: Context, + file: File, + ): Uri = FileProvider.getUriForFile(context, "${context.packageName}$AUTHORITY_SUFFIX", file) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt new file mode 100644 index 0000000000..29cdccc75d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt @@ -0,0 +1,38 @@ +package com.itsaky.androidide.repositories + +import java.io.File + +/** + * Repository interface for template-collection (.cgt) operations. + */ +interface TemplateCollectionRepository { + data class CollectionInfo( + val templateNames: List, + ) + + /** + * Parse and validate a candidate .cgt archive without installing it. + */ + suspend fun inspectCollection(candidateFile: File): Result + + /** + * Returns the filename (without extension) of an already-installed template collection + * matching [baseName] case-insensitively, or `null` if there is no collision. + */ + suspend fun findExistingCollision(baseName: String): String? + + /** + * Install [candidateFile] into the templates directory under [targetBaseName], reloading + * the template provider afterwards. + */ + suspend fun installCollection( + candidateFile: File, + targetBaseName: String, + overwrite: Boolean, + ): Result + + /** + * Check if the templates system is available (i.e. IDE setup has completed). + */ + fun isTemplatesFeatureAvailable(): Boolean +} diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt new file mode 100644 index 0000000000..30aa8784b3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -0,0 +1,83 @@ +package com.itsaky.androidide.repositories + +import android.util.Log +import com.itsaky.androidide.templates.ITemplateProvider +import com.itsaky.androidide.templates.TemplateRecipe +import com.itsaky.androidide.templates.impl.TemplateWarning +import com.itsaky.androidide.templates.impl.zip.ZipTemplateReader +import com.itsaky.androidide.utils.Environment +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION +import java.io.File + +/** + * Implementation of [TemplateCollectionRepository]. Templates are pure data (a zip archive + * copied into [Environment.TEMPLATES_DIR]) so, unlike plugins, installing one never requires an + * app restart - [ITemplateProvider.getInstance] just needs to be reloaded. + */ +class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { + private companion object { + private const val TAG = "TemplateCollectionRepository" + } + + override suspend fun inspectCollection(candidateFile: File): Result = + withContext(Dispatchers.IO) { + runCatching { + val warnings = mutableListOf() + val templates = + ZipTemplateReader.read(candidateFile, warnings) { _, _, _, _, _ -> + TemplateRecipe { null } + } + + if (templates.isEmpty()) { + warnings.forEach { Log.w(TAG, "Template read warning: resId=${it.resId}, args=${it.args}") } + throw IllegalArgumentException("No valid templates found in archive: ${candidateFile.name}") + } + + TemplateCollectionRepository.CollectionInfo( + templateNames = templates.map { it.templateNameStr }, + ) + }.onFailure { exception -> + Log.e(TAG, "Failed to inspect template collection: ${candidateFile.absolutePath}", exception) + } + } + + override suspend fun findExistingCollision(baseName: String): String? = + withContext(Dispatchers.IO) { + Environment.TEMPLATES_DIR + ?.listFiles { file -> file.extension == TEMPLATE_ARCHIVE_EXTENSION } + ?.firstOrNull { it.nameWithoutExtension.equals(baseName, ignoreCase = true) } + ?.nameWithoutExtension + } + + override suspend fun installCollection( + candidateFile: File, + targetBaseName: String, + overwrite: Boolean, + ): Result = + withContext(Dispatchers.IO) { + runCatching { + val templatesDir = + Environment.TEMPLATES_DIR + ?: throw IllegalStateException("Templates system not available") + + val destFile = File(templatesDir, "$targetBaseName.$TEMPLATE_ARCHIVE_EXTENSION") + if (destFile.exists() && !overwrite) { + throw IllegalStateException( + "A template collection named \"$targetBaseName\" already exists", + ) + } + + candidateFile.copyTo(destFile, overwrite = true) + candidateFile.delete() + + ITemplateProvider.getInstance(reload = true) + Unit + }.onFailure { exception -> + Log.e(TAG, "Failed to install template collection: ${candidateFile.absolutePath}", exception) + } + } + + override fun isTemplatesFeatureAvailable(): Boolean = Environment.TEMPLATES_DIR != null +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt new file mode 100644 index 0000000000..6318da1bd6 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt @@ -0,0 +1,47 @@ +package com.itsaky.androidide.ui.models + +import android.net.Uri +import androidx.annotation.StringRes +import com.itsaky.androidide.repositories.TemplateCollectionRepository +import java.io.File + +sealed class ExternalFileInstallUiEvent { + data class ConfirmTemplateInstall( + val tempFile: File, + val targetBaseName: String, + val overwrite: Boolean, + ) : ExternalFileInstallUiEvent() + + data class IgnoreTemplateInstall( + val tempFile: File, + ) : ExternalFileInstallUiEvent() +} + +sealed class ExternalFileInstallUiEffect { + data class ForwardToPluginManager( + val uri: Uri, + ) : ExternalFileInstallUiEffect() + + data class ShowTemplateInstallConfirmation( + val info: TemplateCollectionRepository.CollectionInfo, + val tempFile: File, + val suggestedBaseName: String, + ) : ExternalFileInstallUiEffect() + + data class ShowTemplateNameConflict( + val existingName: String, + val info: TemplateCollectionRepository.CollectionInfo, + val tempFile: File, + ) : ExternalFileInstallUiEffect() + + data class ShowError( + @StringRes val messageResId: Int, + val formatArgs: List = emptyList(), + ) : ExternalFileInstallUiEffect() + + data class ShowSuccess( + @StringRes val messageResId: Int, + ) : ExternalFileInstallUiEffect() + + object Finish : ExternalFileInstallUiEffect() +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt new file mode 100644 index 0000000000..dc3d82162d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -0,0 +1,189 @@ +package com.itsaky.androidide.viewmodels + +import android.content.ContentResolver +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.annotation.StringRes +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.itsaky.androidide.provider.IDEFileProvider +import com.itsaky.androidide.repositories.PluginRepository +import com.itsaky.androidide.repositories.TemplateCollectionRepository +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent +import com.itsaky.androidide.utils.UriFileImporter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION +import java.io.File + +/** + * Handles a `.cgp`/`.cgt` file opened from outside the app (e.g. an email attachment), backing + * [com.itsaky.androidide.activities.ExternalFileInstallActivity]. + */ +class ExternalFileInstallViewModel( + private val pluginRepository: PluginRepository, + private val templateCollectionRepository: TemplateCollectionRepository, + private val contentResolver: ContentResolver, + private val filesDir: File, +) : ViewModel() { + private companion object { + private const val TAG = "ExternalFileInstallVM" + private val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") + } + + // Buffered (not rendezvous): onReceived() runs via Dispatchers.Main.immediate right after + // Activity.onCreate() starts collecting uiEffect, and a synchronous decision path (e.g. an + // unsupported file type) can otherwise complete before the collector actually attaches, + // silently dropping the effect. + private val _uiEffect = Channel(capacity = Channel.BUFFERED) + val uiEffect = _uiEffect.receiveAsFlow() + + /** Call once, from `Activity.onCreate()`, with the VIEW intent's data [Uri]. */ + fun onReceived( + context: Context, + uri: Uri, + ) { + viewModelScope.launch { + val displayName = UriFileImporter.getDisplayName(contentResolver, uri) + val extension = displayName?.substringAfterLast('.', "")?.lowercase() + + if (displayName.isNullOrBlank() || extension.isNullOrBlank()) { + sendErrorAndFinish(R.string.msg_invalid_incoming_file) + return@launch + } + + if (extension != PLUGIN_ARCHIVE_EXTENSION && extension != TEMPLATE_ARCHIVE_EXTENSION) { + sendErrorAndFinish(R.string.msg_unsupported_file_type) + return@launch + } + + if (extension == PLUGIN_ARCHIVE_EXTENSION && !pluginRepository.isPluginManagerAvailable()) { + sendErrorAndFinish(R.string.msg_ide_setup_incomplete) + return@launch + } + + if (extension == TEMPLATE_ARCHIVE_EXTENSION && !templateCollectionRepository.isTemplatesFeatureAvailable()) { + sendErrorAndFinish(R.string.msg_ide_setup_incomplete) + return@launch + } + + val tempFile = + try { + withContext(Dispatchers.IO) { + val tempDir = File(filesDir, "temp").apply { mkdirs() } + val destination = File(tempDir, "incoming_${System.currentTimeMillis()}.$extension") + UriFileImporter.copyUriToFile(contentResolver, uri, destination) { + IllegalStateException("Cannot open file") + } + destination + } + } catch (e: Exception) { + Log.e(TAG, "Failed to copy incoming file", e) + sendErrorAndFinish(R.string.msg_invalid_incoming_file) + return@launch + } + + val baseName = sanitizeBaseName(displayName.substringBeforeLast('.', "templates")) + + if (extension == PLUGIN_ARCHIVE_EXTENSION) { + val fileProviderUri = IDEFileProvider.getUriForFile(context, tempFile) + _uiEffect.trySend(ExternalFileInstallUiEffect.ForwardToPluginManager(fileProviderUri)) + } else { + dispatchTemplateInstall(tempFile, baseName) + } + } + } + + private suspend fun dispatchTemplateInstall( + tempFile: File, + baseName: String, + ) { + val info = + templateCollectionRepository.inspectCollection(tempFile).getOrElse { exception -> + Log.w(TAG, "Invalid template collection file: ${tempFile.name}", exception) + deleteQuietly(tempFile) + sendErrorAndFinish(R.string.msg_template_invalid_file) + return + } + + val existing = templateCollectionRepository.findExistingCollision(baseName) + if (existing == null) { + _uiEffect.trySend( + ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation(info, tempFile, baseName), + ) + } else { + _uiEffect.trySend( + ExternalFileInstallUiEffect.ShowTemplateNameConflict(existing, info, tempFile), + ) + } + } + + fun onEvent(event: ExternalFileInstallUiEvent) { + when (event) { + is ExternalFileInstallUiEvent.ConfirmTemplateInstall -> { + confirmTemplateInstall(event.tempFile, event.targetBaseName, event.overwrite) + } + + is ExternalFileInstallUiEvent.IgnoreTemplateInstall -> { + viewModelScope.launch { + deleteQuietly(event.tempFile) + _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) + } + } + } + } + + private fun confirmTemplateInstall( + tempFile: File, + targetBaseName: String, + overwrite: Boolean, + ) { + viewModelScope.launch { + templateCollectionRepository + .installCollection(tempFile, targetBaseName, overwrite) + .onSuccess { + _uiEffect.trySend(ExternalFileInstallUiEffect.ShowSuccess(R.string.msg_template_installed)) + _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) + }.onFailure { exception -> + Log.e(TAG, "Failed to install template collection", exception) + deleteQuietly(tempFile) + sendErrorAndFinish(R.string.msg_template_install_failed) + } + } + } + + /** Suggests a unique base name for the rename dialog by appending "(2)", "(3)", etc. */ + suspend fun suggestUniqueBaseName(baseName: String): String { + var candidate = baseName + var suffix = 2 + while (templateCollectionRepository.findExistingCollision(candidate) != null) { + candidate = "$baseName ($suffix)" + suffix++ + } + return candidate + } + + fun sanitizeBaseName(rawName: String): String = rawName.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { "templates" } + + private suspend fun sendErrorAndFinish( + @StringRes messageResId: Int, + ) { + _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(messageResId)) + _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) + } + + private suspend fun deleteQuietly(file: File) { + withContext(Dispatchers.IO) { + if (file.exists()) { + file.delete() + } + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt new file mode 100644 index 0000000000..aabbe62307 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt @@ -0,0 +1,136 @@ +package com.itsaky.androidide.repositories + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.plugins.templates.CgtTemplateBuilder +import com.itsaky.androidide.utils.Environment +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class TemplateCollectionRepositoryImplTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var repository: TemplateCollectionRepository + private lateinit var templatesDir: File + private val previousTemplatesDir: File? = Environment.TEMPLATES_DIR + + @Before + fun setup() { + repository = TemplateCollectionRepositoryImpl() + templatesDir = tempFolder.newFolder("templates") + Environment.TEMPLATES_DIR = templatesDir + } + + @After + fun tearDown() { + Environment.TEMPLATES_DIR = previousTemplatesDir + } + + private fun buildCgt( + name: String, + outputDir: File = tempFolder.newFolder(), + ): File = + CgtTemplateBuilder(name) + .description("A test template") + // ZipTemplateReader.read() fully builds a ProjectTemplate (not just metadata) and, + // absent this, falls back to Environment.PROJECTS_DIR - null outside a real app setup. + .defaultSaveLocation(tempFolder.newFolder().absolutePath) + .build(outputDir) + + @Test + fun `isTemplatesFeatureAvailable is true when TEMPLATES_DIR is set`() { + assertThat(repository.isTemplatesFeatureAvailable()).isTrue() + } + + @Test + fun `isTemplatesFeatureAvailable is false when TEMPLATES_DIR is null`() { + Environment.TEMPLATES_DIR = null + assertThat(repository.isTemplatesFeatureAvailable()).isFalse() + } + + @Test + fun `inspectCollection returns template names for a valid archive`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.inspectCollection(cgt) + + assertThat(result.isSuccess).isTrue() + assertThat(result.getOrNull()?.templateNames).containsExactly("Empty Activity") + } + + @Test + fun `inspectCollection fails for a corrupted archive`() = + runTest { + val corrupted = File(tempFolder.newFolder(), "broken.cgt") + corrupted.writeText("not a zip file") + + val result = repository.inspectCollection(corrupted) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `findExistingCollision matches an installed collection case-insensitively`() = + runTest { + File(templatesDir, "MyTemplates.cgt").writeText("placeholder") + + val match = repository.findExistingCollision("mytemplates") + + assertThat(match).isEqualTo("MyTemplates") + } + + @Test + fun `findExistingCollision returns null when there is no match`() = + runTest { + val match = repository.findExistingCollision("does-not-exist") + + assertThat(match).isNull() + } + + @Test + fun `installCollection copies the archive into TEMPLATES_DIR and deletes the source`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "my-templates", overwrite = false) + + assertThat(result.isSuccess).isTrue() + assertThat(File(templatesDir, "my-templates.cgt").exists()).isTrue() + assertThat(cgt.exists()).isFalse() + } + + @Test + fun `installCollection without overwrite fails when the destination already exists`() = + runTest { + File(templatesDir, "my-templates.cgt").writeText("existing") + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "my-templates", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection with overwrite replaces the existing destination`() = + runTest { + val destination = File(templatesDir, "my-templates.cgt") + destination.writeText("stale content") + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "my-templates", overwrite = true) + + assertThat(result.isSuccess).isTrue() + assertThat(destination.readText()).isNotEqualTo("stale content") + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt new file mode 100644 index 0000000000..4eaa834d62 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt @@ -0,0 +1,176 @@ +package com.itsaky.androidide.viewmodels + +import android.content.Context +import android.net.Uri +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.repositories.PluginRepository +import com.itsaky.androidide.repositories.TemplateCollectionRepository +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect +import com.itsaky.androidide.viewmodel.MainDispatcherRule +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@OptIn(ExperimentalCoroutinesApi::class) +class ExternalFileInstallViewModelTest { + @get:Rule + val instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @get:Rule + val tempFolder = TemporaryFolder() + + private val context: Context = ApplicationProvider.getApplicationContext() + private val pluginRepository = mockk(relaxed = true) + private val templateCollectionRepository = mockk(relaxed = true) + + private lateinit var viewModel: ExternalFileInstallViewModel + + @Before + fun setup() { + viewModel = + ExternalFileInstallViewModel( + pluginRepository = pluginRepository, + templateCollectionRepository = templateCollectionRepository, + contentResolver = context.contentResolver, + filesDir = context.filesDir, + ) + } + + private fun sourceUriFor( + fileName: String, + content: String = "dummy", + ): Uri { + val file = File(tempFolder.newFolder(), fileName) + file.writeText(content) + return Uri.fromFile(file) + } + + @Test + fun `unsupported extension shows error and finishes`() = + runTest { + viewModel.onReceived(context, sourceUriFor("notes.txt")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + } + + @Test + fun `cgp when plugin manager unavailable shows setup-incomplete error`() = + runTest { + stubPluginManagerAvailable(false) + + viewModel.onReceived(context, sourceUriFor("my-plugin.cgp")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + } + + @Test + fun `cgt when templates unavailable shows setup-incomplete error`() = + runTest { + stubTemplatesFeatureAvailable(false) + + viewModel.onReceived(context, sourceUriFor("my-templates.cgt")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + } + + @Test + fun `fresh cgp forwards to plugin manager`() = + runTest { + stubPluginManagerAvailable(true) + + viewModel.onReceived(context, sourceUriFor("my-plugin.cgp")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java) + } + + @Test + fun `fresh cgt with no name collision shows install confirmation`() = + runTest { + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + + viewModel.onReceived(context, sourceUriFor("my-templates.cgt")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation::class.java) + val effect = first as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + assertThat(effect.suggestedBaseName).isEqualTo("my-templates") + assertThat(effect.info.templateNames).containsExactly("Empty Activity") + } + + @Test + fun `cgt with existing name collision shows name conflict`() = + runTest { + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns "my-templates" + + viewModel.onReceived(context, sourceUriFor("my-templates.cgt")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateNameConflict::class.java) + assertThat((first as ExternalFileInstallUiEffect.ShowTemplateNameConflict).existingName).isEqualTo("my-templates") + } + + @Test + fun `invalid cgt shows invalid-file error`() = + runTest { + stubTemplatesFeatureAvailable(true) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns + Result.failure(IllegalArgumentException("no templates")) + + viewModel.onReceived(context, sourceUriFor("broken.cgt")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + } + + @Test + fun `sanitizeBaseName strips filesystem-unsafe characters`() { + assertThat(viewModel.sanitizeBaseName("my:templates/v2")).isEqualTo("my_templates_v2") + assertThat(viewModel.sanitizeBaseName(" ")).isEqualTo("templates") + } + + @Test + fun `suggestUniqueBaseName bumps suffix until free`() = + runTest { + coEvery { templateCollectionRepository.findExistingCollision("foo") } returns "foo" + coEvery { templateCollectionRepository.findExistingCollision("foo (2)") } returns "foo (2)" + coEvery { templateCollectionRepository.findExistingCollision("foo (3)") } returns null + + val suggested = viewModel.suggestUniqueBaseName("foo") + + assertThat(suggested).isEqualTo("foo (3)") + } + + private fun stubPluginManagerAvailable(available: Boolean) { + every { pluginRepository.isPluginManagerAvailable() } returns available + } + + private fun stubTemplatesFeatureAvailable(available: Boolean) { + every { templateCollectionRepository.isTemplatesFeatureAvailable() } returns available + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 97d441fbbb..305eeb0cb6 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1089,6 +1089,20 @@ https://www.appdevforall.org/contribute/ %1$s: %2$s + Could not read the file. It may be corrupted or unavailable. + Unsupported file type. Only .cgp and .cgt files can be opened this way. + IDE setup has not finished yet. Please try again once setup completes. + Install Template Collection + Install \'%1$s\' with the following templates: %2$s? + Template Collection Already Installed + A template collection named \'%1$s\' is already installed. What would you like to do? + Overwrite + Rename & Install + New collection name + Template collection installed successfully + Invalid or corrupted template collection file. + Failed to install template collection. + \n\nProject creation finished with warnings/errors. Open IDE Logs for details. From fbe3855263dc99f41081afaa60a1a5bacdc1f058 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 15 Aug 2026 22:54:11 -0700 Subject: [PATCH 03/23] ADFA-4934: Split file:// intent-filter into typed/untyped variants On-device testing (dumpsys package) showed a mimeType on any tag applies to the WHOLE intent-filter, not just that tag - so a single filter mixing content's mimeType-bearing variant with file's mimeType-less variant silently broke matching for untyped file:// intents (confirmed via `pm query-activities`: 0 matches before this fix, 2 after). content:// keeps a single filter (the OS resolves an implicit type for it regardless), but file:// now gets two dedicated filters, one typed and one not. Verified end-to-end on a physical device: the "Open with" chooser lists Code on the Go for both .cgp and .cgt, and the full install/conflict-resolve flow (fresh install, rename, overwrite, invalid-file rejection) works. Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 779a34f591..a11d446d7c 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -119,8 +119,14 @@ + + + + + - + + + + + @@ -143,8 +158,17 @@ + + + + + - + + + + + From 7642a9992e258a172fe35f29ea7131fe295b9861 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sat, 15 Aug 2026 23:09:23 -0700 Subject: [PATCH 04/23] ADFA-4934: Rebuild the .cgt install dialogs in Jetpack Compose ADR 0009 requires new dialogs to be Compose, not MaterialAlertDialogBuilder - caught by an architecture-review pass before opening the PR. Enables Compose in the app module (mirroring floating-window's setup) and rewrites the three .cgt dialogs (install-confirm, name-conflict, rename) as composables, reusing FloatingTheme so they stay visually consistent with the IDE's XML theme. The .cgp path is untouched: it still forwards into PluginManagerActivity's existing (pre-ADR) dialog rather than duplicating it. Fixed two things surfaced by this rewrite: - compose-rules ktlint caught the ViewModel being forwarded into a nested composable; fixed via state hoisting (a plain suspend lambda instead). - The rename dialog's suggested name no longer visually clips its first character - that was a View EditText auto-scroll artifact from selectAll(), gone now that Compose's TextFieldValue sets the cursor position explicitly. Re-verified end-to-end on the physical device: fresh install, rename, overwrite, and the .cgp forwarding path all work with the new dialogs. Co-Authored-By: Claude Sonnet 5 --- app/build.gradle.kts | 15 ++ .../activities/ExternalFileInstallActivity.kt | 139 +--------- .../activities/ExternalFileInstallScreen.kt | 252 ++++++++++++++++++ 3 files changed, 273 insertions(+), 133 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f4fdf7ddc..8fed4c3283 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -34,6 +34,7 @@ plugins { // Sentry gradle plugin; the SDK it wires up reports to our GlitchTip backend. alias(libs.plugins.sentry) alias(libs.plugins.google.services) + alias(libs.plugins.kotlin.compose) } fun propOrEnv(name: String): String = @@ -102,6 +103,10 @@ android { generateLocaleConfig = true } + buildFeatures { + compose = true + } + sourceSets { getByName("androidTest") { manifest.srcFile("src/androidTest/AndroidManifest.xml") @@ -241,6 +246,16 @@ dependencies { // Git implementation(libs.git.jgit) + // Compose (ADR 0009 - new IDE dialogs/screens are Compose) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.activity) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + // AndroidX implementation(libs.androidx.splashscreen) implementation(libs.androidx.annotation) diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt index 00688eb2ab..1b8d3a3565 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt @@ -1,39 +1,28 @@ package com.itsaky.androidide.activities -import android.content.Intent import android.os.Bundle import android.view.View -import android.widget.FrameLayout -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.itsaky.androidide.R +import androidx.compose.ui.platform.ComposeView import com.itsaky.androidide.app.IDEActivity -import com.itsaky.androidide.databinding.RenameProjectTextinputBinding -import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect -import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent -import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.flashSuccess import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel -import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel /** * Trampoline activity that receives a `.cgp`/`.cgt` file opened from outside the app (e.g. an * email attachment), prompts to install it, and finishes - it has no content of its own beyond - * the dialogs it shows. + * the dialogs [ExternalFileInstallScreen] shows. */ class ExternalFileInstallActivity : IDEActivity() { private val viewModel: ExternalFileInstallViewModel by viewModel() - override fun bindLayout(): View = FrameLayout(this) + override fun bindLayout(): View = + ComposeView(this).apply { + setContent { ExternalFileInstallScreen(viewModel) } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - observeUiEffects() - val uri = intent?.data if (uri == null) { finish() @@ -44,120 +33,4 @@ class ExternalFileInstallActivity : IDEActivity() { viewModel.onReceived(this, uri) } } - - private fun observeUiEffects() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiEffect.collect { effect -> handleUiEffect(effect) } - } - } - } - - private fun handleUiEffect(effect: ExternalFileInstallUiEffect) { - when (effect) { - is ExternalFileInstallUiEffect.ForwardToPluginManager -> { - startActivity( - Intent(this, PluginManagerActivity::class.java) - .putExtra(PluginManagerActivity.EXTRA_PENDING_INSTALL_URI, effect.uri), - ) - finish() - } - - is ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation -> { - showInstallConfirmation(effect) - } - - is ExternalFileInstallUiEffect.ShowTemplateNameConflict -> { - showNameConflict(effect) - } - - is ExternalFileInstallUiEffect.ShowError -> { - flashError(getString(effect.messageResId, *effect.formatArgs.toTypedArray())) - } - - is ExternalFileInstallUiEffect.ShowSuccess -> { - flashSuccess(getString(effect.messageResId)) - } - - is ExternalFileInstallUiEffect.Finish -> { - finish() - } - } - } - - private fun showInstallConfirmation(effect: ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_install_template_collection) - .setMessage( - getString( - R.string.msg_template_install_confirm, - effect.suggestedBaseName, - effect.info.templateNames.joinToString(", "), - ), - ).setPositiveButton(R.string.btn_install) { _, _ -> - viewModel.onEvent( - ExternalFileInstallUiEvent.ConfirmTemplateInstall( - tempFile = effect.tempFile, - targetBaseName = effect.suggestedBaseName, - overwrite = false, - ), - ) - }.setNegativeButton(android.R.string.cancel) { _, _ -> - viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) - }.setOnCancelListener { - viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) - }.show() - } - - private fun showNameConflict(effect: ExternalFileInstallUiEffect.ShowTemplateNameConflict) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_template_already_installed) - .setMessage(getString(R.string.msg_template_name_conflict, effect.existingName)) - .setPositiveButton(R.string.btn_rename_and_install) { _, _ -> - showRenameDialog(effect) - }.setNeutralButton(R.string.btn_overwrite) { _, _ -> - viewModel.onEvent( - ExternalFileInstallUiEvent.ConfirmTemplateInstall( - tempFile = effect.tempFile, - targetBaseName = effect.existingName, - overwrite = true, - ), - ) - }.setNegativeButton(android.R.string.cancel) { _, _ -> - viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) - }.setOnCancelListener { - viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) - }.show() - } - - private fun showRenameDialog(effect: ExternalFileInstallUiEffect.ShowTemplateNameConflict) { - val binding = RenameProjectTextinputBinding.inflate(layoutInflater) - binding.textinputLayout.hint = getString(R.string.hint_new_template_collection_name) - - val dialog = - MaterialAlertDialogBuilder(this) - .setTitle(R.string.btn_rename_and_install) - .setView(binding.root) - .setPositiveButton(R.string.btn_install) { _, _ -> - val newName = viewModel.sanitizeBaseName(binding.textinputEdittext.text.toString()) - viewModel.onEvent( - ExternalFileInstallUiEvent.ConfirmTemplateInstall( - tempFile = effect.tempFile, - targetBaseName = newName, - overwrite = false, - ), - ) - }.setNegativeButton(android.R.string.cancel) { _, _ -> - viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) - }.setOnCancelListener { - viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) - }.show() - - lifecycleScope.launch { - val suggested = viewModel.suggestUniqueBaseName(effect.existingName) - if (!dialog.isShowing) return@launch - binding.textinputEdittext.setText(suggested) - binding.textinputEdittext.selectAll() - } - } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt new file mode 100644 index 0000000000..94e088bc8c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt @@ -0,0 +1,252 @@ +package com.itsaky.androidide.activities + +import android.app.Activity +import android.content.Intent +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import com.itsaky.androidide.R +import com.itsaky.androidide.floating.ui.FloatingTheme +import com.itsaky.androidide.repositories.TemplateCollectionRepository +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel +import java.io.File + +private sealed interface DialogUiState { + object None : DialogUiState + + data class InstallConfirm( + val info: TemplateCollectionRepository.CollectionInfo, + val tempFile: File, + val suggestedBaseName: String, + ) : DialogUiState + + data class NameConflict( + val existingName: String, + val tempFile: File, + ) : DialogUiState + + data class Rename( + val existingName: String, + val tempFile: File, + ) : DialogUiState +} + +@Composable +fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { + val context = LocalContext.current + var dialogState by remember { mutableStateOf(DialogUiState.None) } + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is ExternalFileInstallUiEffect.ForwardToPluginManager -> { + context.startActivity( + Intent(context, PluginManagerActivity::class.java) + .putExtra(PluginManagerActivity.EXTRA_PENDING_INSTALL_URI, effect.uri), + ) + (context as? Activity)?.finish() + } + + is ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation -> { + dialogState = + DialogUiState.InstallConfirm(effect.info, effect.tempFile, effect.suggestedBaseName) + } + + is ExternalFileInstallUiEffect.ShowTemplateNameConflict -> { + dialogState = DialogUiState.NameConflict(effect.existingName, effect.tempFile) + } + + is ExternalFileInstallUiEffect.ShowError -> { + flashError(context.getString(effect.messageResId, *effect.formatArgs.toTypedArray())) + } + + is ExternalFileInstallUiEffect.ShowSuccess -> { + flashSuccess(context.getString(effect.messageResId)) + } + + is ExternalFileInstallUiEffect.Finish -> { + (context as? Activity)?.finish() + } + } + } + } + + FloatingTheme { + when (val state = dialogState) { + is DialogUiState.InstallConfirm -> { + InstallConfirmationDialog( + state = state, + onInstall = { + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + tempFile = state.tempFile, + targetBaseName = state.suggestedBaseName, + overwrite = false, + ), + ) + dialogState = DialogUiState.None + }, + onDismiss = { + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile)) + dialogState = DialogUiState.None + }, + ) + } + + is DialogUiState.NameConflict -> { + NameConflictDialog( + state = state, + onOverwrite = { + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + tempFile = state.tempFile, + targetBaseName = state.existingName, + overwrite = true, + ), + ) + dialogState = DialogUiState.None + }, + onRename = { dialogState = DialogUiState.Rename(state.existingName, state.tempFile) }, + onDismiss = { + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile)) + dialogState = DialogUiState.None + }, + ) + } + + is DialogUiState.Rename -> { + RenameDialog( + state = state, + suggestName = viewModel::suggestUniqueBaseName, + onConfirm = { newName -> + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + tempFile = state.tempFile, + targetBaseName = viewModel.sanitizeBaseName(newName), + overwrite = false, + ), + ) + dialogState = DialogUiState.None + }, + onDismiss = { + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile)) + dialogState = DialogUiState.None + }, + ) + } + + DialogUiState.None -> { + Unit + } + } + } +} + +@Composable +private fun InstallConfirmationDialog( + state: DialogUiState.InstallConfirm, + onInstall: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_install_template_collection)) }, + text = { + Text( + stringResource( + R.string.msg_template_install_confirm, + state.suggestedBaseName, + state.info.templateNames.joinToString(", "), + ), + ) + }, + confirmButton = { + TextButton(onClick = onInstall) { Text(stringResource(R.string.btn_install)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +@Composable +private fun NameConflictDialog( + state: DialogUiState.NameConflict, + onOverwrite: () -> Unit, + onRename: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_template_already_installed)) }, + text = { Text(stringResource(R.string.msg_template_name_conflict, state.existingName)) }, + confirmButton = { + TextButton(onClick = onOverwrite) { Text(stringResource(R.string.btn_overwrite)) } + }, + dismissButton = { + Row { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + TextButton(onClick = onRename) { Text(stringResource(R.string.btn_rename_and_install)) } + } + }, + ) +} + +@Composable +private fun RenameDialog( + state: DialogUiState.Rename, + suggestName: suspend (String) -> String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, +) { + var name by remember { mutableStateOf(TextFieldValue(state.existingName)) } + var suggestionReady by remember { mutableStateOf(false) } + val currentSuggestName by rememberUpdatedState(suggestName) + + LaunchedEffect(state.existingName) { + val suggested = currentSuggestName(state.existingName) + name = TextFieldValue(suggested, selection = TextRange(suggested.length)) + suggestionReady = true + } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.btn_rename_and_install)) }, + text = { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.hint_new_template_collection_name)) }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { onConfirm(name.text) }, + enabled = suggestionReady && name.text.isNotBlank(), + ) { + Text(stringResource(R.string.btn_install)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} From 367037796d8508eaca7dbc6a9bd62756eb2e9b96 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 16 Aug 2026 09:01:46 -0700 Subject: [PATCH 05/23] ADFA-4934: Dedupe FileProvider authority; fix manifest case/coverage gaps Code review (PR #1682) findings, mechanical/data half: - The app's FileProvider authority string (".providers.fileprovider") was duplicated inline across 7 call sites (IntentUtils, ApkInstaller, FileDragStarter, DragAndDropExtensions, FeedbackManager, FeedbackEmailHandler, and the new IDEFileProvider helper) - a rename would have needed 7 manual updates with no compiler check. Consolidated into common/FileProviderUtils.kt, shared across app and common (which can't depend on app's IDEFileProvider). - Manifest: android:pathPattern has no case-insensitive mode, so a .CGP/.CGT (uppercase) attachment previously never matched. Added uppercase variants, and combined .cgp/.cgt into 3 shared filters (down from 6) since every tag within one filter already had to share the same mimeType-bearing shape. Documented, as an explicit known limitation, that a sender whose content:// Uri path never carries the filename (e.g. some email providers' attachment Uris) can't match a pathPattern-based filter regardless of type - the alternative (a pathPattern-less mimeType="*/*" filter) would register this app as a candidate for every file-view intent on the device, which is a worse tradeoff than missing those senders. Verified on a physical device: `pm query-activities` now matches both cases of both extensions via content:// and file://, typed and untyped. Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 39 +- .../androidide/dnd/DragAndDropExtensions.kt | 59 ++- .../itsaky/androidide/dnd/FileDragStarter.kt | 140 ++++--- .../androidide/provider/IDEFileProvider.kt | 5 +- .../itsaky/androidide/utils/ApkInstaller.kt | 53 ++- .../itsaky/androidide/utils/IntentUtils.kt | 8 +- .../androidide/utils/FeedbackEmailHandler.kt | 188 +++++---- .../androidide/utils/FeedbackManager.kt | 377 +++++++++--------- .../androidide/utils/FileProviderUtils.kt | 24 ++ 9 files changed, 466 insertions(+), 427 deletions(-) create mode 100644 common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a11d446d7c..d52f67278e 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -127,8 +127,20 @@ OS leniency and matches even when the incoming intent has no explicit type, so one filter with mimeType="*/*" covers both typed and untyped content:// intents; `file` gets no such leniency and needs its typed and untyped cases in separate filters. + Both extensions are combined into each filter below (safe: every tag within + a given filter shares the same mimeType-or-not shape, so there's no cross-extension + contamination), and each extension gets both a lowercase and an UPPERCASE pathPattern + - android:pathPattern has no case-insensitive mode, and some senders (e.g. archives + re-exported from Windows tools) produce uppercase extensions. android:host="*" is required alongside pathPattern - the manifest matcher only - evaluates pathPattern when host is also present. --> + evaluates pathPattern when host is also present. + Known limitation, not fixable via manifest matching: a sender whose content:// Uri + path never carries the filename/extension at all (some email providers' attachment + Uris look like content://.../message_attachment/12345/0/ATTACHMENT/false) can't match + a pathPattern-based filter regardless of type. The only alternative - a pathPattern- + less, mimeType="*/*" filter - would register this app as a candidate handler for + every file view intent on the device, which is a worse tradeoff than missing those + senders. --> - - - - - - - - - - - - - - - - - + + + + + + + + diff --git a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt index 3d27258387..1fa28ebf90 100644 --- a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt +++ b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt @@ -7,50 +7,49 @@ import android.content.Context import android.net.Uri import android.view.DragEvent import androidx.core.net.toUri +import com.itsaky.androidide.utils.fileProviderAuthority /** * Checks if the [DragEvent] contains any URIs that can be imported into the project. */ fun DragEvent.hasImportableContent(context: Context): Boolean { - if (localState != null) return false - - return when (action) { - DragEvent.ACTION_DROP -> { - val clip = clipData ?: return false - (0 until clip.itemCount).any { index -> - clip.getItemAt(index).toImportableExternalUris(context).isNotEmpty() - } - } - - else -> clipDescription?.hasImportableMimeType() == true - } + if (localState != null) return false + + return when (action) { + DragEvent.ACTION_DROP -> { + val clip = clipData ?: return false + (0 until clip.itemCount).any { index -> + clip.getItemAt(index).toImportableExternalUris(context).isNotEmpty() + } + } + + else -> { + clipDescription?.hasImportableMimeType() == true + } + } } /** * Resolves the [ClipData.Item] to a list of external [Uri]s, ignoring internal application URIs. */ -fun ClipData.Item.toImportableExternalUris(context: Context): List { - return toExternalUris().filterNot { it.isInternalDragUri(context) } -} +fun ClipData.Item.toImportableExternalUris(context: Context): List = toExternalUris().filterNot { it.isInternalDragUri(context) } -private fun Uri.isInternalDragUri(context: Context): Boolean { - return authority == "${context.packageName}.providers.fileprovider" -} +private fun Uri.isInternalDragUri(context: Context): Boolean = authority == context.fileProviderAuthority() private fun ClipData.Item.toExternalUris(): List { - uri?.let { return listOf(it) } + uri?.let { return listOf(it) } - val textContent = text?.toString() ?: return emptyList() + val textContent = text?.toString() ?: return emptyList() - return textContent.lineSequence() - .map { it.trim() } - .map { it.toUri() } - .filter { it.scheme == ContentResolver.SCHEME_CONTENT || it.scheme == ContentResolver.SCHEME_FILE } - .toList() + return textContent + .lineSequence() + .map { it.trim() } + .map { it.toUri() } + .filter { it.scheme == ContentResolver.SCHEME_CONTENT || it.scheme == ContentResolver.SCHEME_FILE } + .toList() } -private fun ClipDescription.hasImportableMimeType(): Boolean { - return hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST) || - hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || - hasMimeType("*/*") -} +private fun ClipDescription.hasImportableMimeType(): Boolean = + hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST) || + hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || + hasMimeType("*/*") diff --git a/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt b/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt index cb30e0c79f..7498137eb0 100644 --- a/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt +++ b/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt @@ -5,89 +5,97 @@ import android.content.Context import android.net.Uri import android.view.View import android.webkit.MimeTypeMap -import androidx.core.content.FileProvider import androidx.core.view.ViewCompat +import com.itsaky.androidide.utils.fileProviderUriFor import java.io.File import java.util.Locale sealed interface FileDragResult { - data object Started : FileDragResult - data class Failed(val error: FileDragError) : FileDragResult + data object Started : FileDragResult + + data class Failed( + val error: FileDragError, + ) : FileDragResult } sealed interface FileDragError { - data object FileNotFound : FileDragError - data object NotAFile : FileDragError - data object SystemRejected : FileDragError - data class Exception(val throwable: Throwable) : FileDragError + data object FileNotFound : FileDragError + + data object NotAFile : FileDragError + + data object SystemRejected : FileDragError + + data class Exception( + val throwable: Throwable, + ) : FileDragError } class FileDragStarter( - private val context: Context, + private val context: Context, ) { + fun startDrag( + sourceView: View, + file: File, + ): FileDragResult { + if (!file.exists()) { + return FileDragResult.Failed(FileDragError.FileNotFound) + } - fun startDrag(sourceView: View, file: File): FileDragResult { - if (!file.exists()) { - return FileDragResult.Failed(FileDragError.FileNotFound) - } - - if (!file.isFile) { - return FileDragResult.Failed(FileDragError.NotAFile) - } - - return runCatching { - val contentUri = buildContentUri(file) - val mimeType = resolveMimeType(file) - val clipData = buildClipData(file, contentUri, mimeType) - val dragShadow = View.DragShadowBuilder(sourceView) + if (!file.isFile) { + return FileDragResult.Failed(FileDragError.NotAFile) + } - ViewCompat.startDragAndDrop( - sourceView, - clipData, - dragShadow, - null, - DRAG_FLAGS, - ) - }.fold( - onSuccess = { started -> - if (started) FileDragResult.Started - else FileDragResult.Failed(FileDragError.SystemRejected) - }, - onFailure = { throwable -> - FileDragResult.Failed(FileDragError.Exception(throwable)) - }, - ) - } + return runCatching { + val contentUri = buildContentUri(file) + val mimeType = resolveMimeType(file) + val clipData = buildClipData(file, contentUri, mimeType) + val dragShadow = View.DragShadowBuilder(sourceView) - private fun buildContentUri(file: File): Uri { - return FileProvider.getUriForFile(context, fileProviderAuthority, file) - } + ViewCompat.startDragAndDrop( + sourceView, + clipData, + dragShadow, + null, + DRAG_FLAGS, + ) + }.fold( + onSuccess = { started -> + if (started) { + FileDragResult.Started + } else { + FileDragResult.Failed(FileDragError.SystemRejected) + } + }, + onFailure = { throwable -> + FileDragResult.Failed(FileDragError.Exception(throwable)) + }, + ) + } - private fun resolveMimeType(file: File): String { - val extension = file.extension.lowercase(Locale.ROOT) - return MimeTypeMap.getSingleton() - .getMimeTypeFromExtension(extension) - ?: DEFAULT_MIME_TYPE - } + private fun buildContentUri(file: File): Uri = context.fileProviderUriFor(file) - private fun buildClipData( - file: File, - contentUri: Uri, - mimeType: String, - ): ClipData { - return ClipData( - file.name, - arrayOf(mimeType), - ClipData.Item(contentUri), - ) - } + private fun resolveMimeType(file: File): String { + val extension = file.extension.lowercase(Locale.ROOT) + return MimeTypeMap + .getSingleton() + .getMimeTypeFromExtension(extension) + ?: DEFAULT_MIME_TYPE + } - private val fileProviderAuthority: String - get() = "${context.packageName}.providers.fileprovider" + private fun buildClipData( + file: File, + contentUri: Uri, + mimeType: String, + ): ClipData = + ClipData( + file.name, + arrayOf(mimeType), + ClipData.Item(contentUri), + ) - private companion object { - private const val DEFAULT_MIME_TYPE = "application/octet-stream" - private const val DRAG_FLAGS = - View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ - } + private companion object { + private const val DEFAULT_MIME_TYPE = "application/octet-stream" + private const val DRAG_FLAGS = + View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ + } } diff --git a/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt b/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt index e7deea1e73..0720026267 100644 --- a/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt @@ -20,6 +20,7 @@ package com.itsaky.androidide.provider import android.content.Context import android.net.Uri import androidx.core.content.FileProvider +import com.itsaky.androidide.utils.fileProviderUriFor import java.io.File /** @@ -29,8 +30,6 @@ import java.io.File */ class IDEFileProvider : FileProvider() { companion object { - private const val AUTHORITY_SUFFIX = ".providers.fileprovider" - /** * Mint a `content://` [Uri] for [file] via this provider, so it can be shared with * another component in this app without relying on a Uri permission grant to have @@ -40,6 +39,6 @@ class IDEFileProvider : FileProvider() { fun getUriForFile( context: Context, file: File, - ): Uri = FileProvider.getUriForFile(context, "${context.packageName}$AUTHORITY_SUFFIX", file) + ): Uri = context.fileProviderUriFor(file) } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt index 16a28891a9..12732686bc 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt @@ -8,7 +8,6 @@ import android.content.pm.PackageInstaller import android.content.pm.PackageManager import android.os.Process import androidx.core.app.PendingIntentCompat -import androidx.core.content.FileProvider import com.itsaky.androidide.actions.build.DebugAction import com.itsaky.androidide.buildinfo.BuildInfo import com.itsaky.androidide.services.InstallationResultReceiver @@ -23,7 +22,6 @@ import java.io.File * @author Akash Yadav */ object ApkInstaller { - private val log = LoggerFactory.getLogger(ApkInstaller::class.java) private const val DEBUG_FALLBACK_INSTALLER = false @@ -40,9 +38,10 @@ object ApkInstaller { launchInDebugMode: Boolean = false, debugFallbackInstaller: Boolean = DEBUG_FALLBACK_INSTALLER, ): Boolean { - val isValidApk = withContext(Dispatchers.IO) { - apk.exists() && apk.isFile && apk.extension == "apk" - } + val isValidApk = + withContext(Dispatchers.IO) { + apk.exists() && apk.isFile && apk.extension == "apk" + } if (!isValidApk) { log.error("File is not an APK: {}", apk) return false @@ -60,7 +59,7 @@ object ApkInstaller { if (DeviceUtils.isMiui() || debugFallbackInstaller) { log.warn( "Cannot use session-based installer on this device." + - " Falling back to intent-based installer." + " Falling back to intent-based installer.", ) installUsingIntent(context, apk, baseIntent) @@ -71,9 +70,12 @@ object ApkInstaller { } @Suppress("DEPRECATION", "RequestInstallPackagesPolicy") - private fun installUsingIntent(context: Context, apk: File, intent: Intent) { - val authority = "${context.packageName}.providers.fileprovider" - val uri = FileProvider.getUriForFile(context, authority, apk) + private fun installUsingIntent( + context: Context, + apk: File, + intent: Intent, + ) { + val uri = context.fileProviderUriFor(apk) intent.setAction(Intent.ACTION_INSTALL_PACKAGE) intent.setDataAndType(uri, "application/vnd.android.package-archive") intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK @@ -101,15 +103,18 @@ object ApkInstaller { try { session = installer.openSession(sessionId) - val callback = requireNotNull(getCallbackIntent(context, intent, sessionId)) { - "PackageInstaller callback intent is null" - } + val callback = + requireNotNull(getCallbackIntent(context, intent, sessionId)) { + "PackageInstaller callback intent is null" + } addToSession(session, apk) session.commit(callback.intentSender) } catch (t: Throwable) { runCatching { installer.abandonSession(sessionId) } throw t - } finally { session?.close() } + } finally { + session?.close() + } } }.onFailure { error -> log.error("Package installation failed", error) @@ -143,14 +148,18 @@ object ApkInstaller { } } - private fun getCallbackIntent(context: Context, intent: Intent, sessionId: Int): PendingIntent? { - val intent = intent.apply { - action = InstallationResultReceiver.ACTION_INSTALL_STATUS - setClass(context, InstallationResultReceiver::class.java) - setPackage(context.packageName) - addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - } - + private fun getCallbackIntent( + context: Context, + intent: Intent, + sessionId: Int, + ): PendingIntent? { + val intent = + intent.apply { + action = InstallationResultReceiver.ACTION_INSTALL_STATUS + setClass(context, InstallationResultReceiver::class.java) + setPackage(context.packageName) + addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + } return PendingIntentCompat.getBroadcast( context, @@ -177,4 +186,4 @@ object ApkInstaller { session.fsync(outStream) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt index 3655a19784..0bcf662ba4 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt @@ -22,7 +22,6 @@ import android.content.Intent import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.ShareCompat -import androidx.core.content.FileProvider import com.itsaky.androidide.R import com.itsaky.androidide.utils.ImageUtils.ImageType.TYPE_UNKNOWN import org.slf4j.LoggerFactory @@ -88,12 +87,7 @@ object IntentUtils { mimeType: String = MIME_ANY, intentAction: String = Intent.ACTION_SEND, ) { - val uri = - FileProvider.getUriForFile( - context, - "${context.packageName}.providers.fileprovider", - file, - ) + val uri = context.fileProviderUriFor(file) val intent = ShareCompat .IntentBuilder(context) diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt index fc45eced78..19e208e1c8 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt @@ -8,7 +8,6 @@ import android.net.Uri import android.os.Handler import android.os.Looper import android.view.PixelCopy -import androidx.core.content.FileProvider import androidx.core.graphics.createBitmap import androidx.core.net.toUri import com.itsaky.androidide.common.R @@ -26,16 +25,17 @@ import kotlin.coroutines.suspendCoroutine class FeedbackEmailHandler( val context: Context, ) { - - companion object { - const val AUTHORITY_SUFFIX = "providers.fileprovider" - const val SCREENSHOTS_DIR = "feedback_screenshots" - const val LOGS_DIR = "feedback_logs" - const val MAX_EMAIL_BODY_CHARS = 50_000 + companion object { + const val SCREENSHOTS_DIR = "feedback_screenshots" + const val LOGS_DIR = "feedback_logs" + const val MAX_EMAIL_BODY_CHARS = 50_000 private val log = LoggerFactory.getLogger(FeedbackEmailHandler::class.java) } - private fun sanitizeEmailBody(body: String, hasLogAttachment: Boolean = true): String { + private fun sanitizeEmailBody( + body: String, + hasLogAttachment: Boolean = true, + ): String { if (body.length <= MAX_EMAIL_BODY_CHARS) return body val suffix = if (hasLogAttachment) " See attached file." else "" return buildString { @@ -46,9 +46,7 @@ class FeedbackEmailHandler( } } - suspend fun captureAndPrepareScreenshotUri( - activity: Activity, - ): Uri? { + suspend fun captureAndPrepareScreenshotUri(activity: Activity): Uri? { val rootView = activity.window?.decorView?.rootView ?: return null if (rootView.width <= 0 || rootView.height <= 0 || !rootView.isShown) return null @@ -90,101 +88,99 @@ class FeedbackEmailHandler( val screenshotsDir = File(context.filesDir, SCREENSHOTS_DIR).apply { mkdirs() } val timestamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()).format(Date()) - val filename = "Screenshot ${timestamp}.jpg" + val filename = "Screenshot $timestamp.jpg" val screenshotFile = File(screenshotsDir, filename) FileOutputStream(screenshotFile).use { out -> bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out) } - val authority = "${context.packageName}.$AUTHORITY_SUFFIX" - val uri = FileProvider.getUriForFile(context, authority, screenshotFile) - uri + context.fileProviderUriFor(screenshotFile) } catch (e: Exception) { log.error(context.getString(R.string.failed_to_save_bitmap_to_file), e) null } - suspend fun getLogUri( - context: Context, - logContent: String?, - ): Uri? = - withContext(Dispatchers.IO) { - when { - logContent.isNullOrEmpty() -> null - - else -> { - try { - val logsDir = File(context.filesDir, LOGS_DIR).apply { mkdirs() } - val timestamp = - SimpleDateFormat( - "yyyy-MM-dd_HH-mm-ss", - Locale.getDefault() - ).format(Date()) - val filename = "Feedback Log ${timestamp}.txt" - val logFile = File(logsDir, filename) - logFile.writeText(logContent) - val authority = "${context.packageName}.$AUTHORITY_SUFFIX" - val uri = FileProvider.getUriForFile(context, authority, logFile) - uri - } catch (e: Exception) { - log.error(context.getString(R.string.msg_file_creation_failed), e) - null - } - } - } - } - - fun prepareEmailIntent( - screenshotUri: Uri?, - logContentUri: Uri?, - emailRecipient: String, - subject: String, - body: String, - ): Intent { - val attachmentUris = mutableListOf() - screenshotUri?.let { attachmentUris.add(it) } - logContentUri?.let { attachmentUris.add(it) } - - return getIntentBasedOnAttachments( - emailRecipient = emailRecipient, - subject = subject, - body = body, - attachmentUris = attachmentUris, - hasLogAttachment = logContentUri != null - ) - } - - fun getIntentBasedOnAttachments( - emailRecipient: String, - subject: String, - body: String, - attachmentUris: MutableList, - hasLogAttachment: Boolean = false - ): Intent { - val safeBody = sanitizeEmailBody(body, hasLogAttachment) - return when { - // No screenshot or log file (if both files failed to be created) - attachmentUris.isEmpty() -> { - Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient)) - putExtra(Intent.EXTRA_SUBJECT, subject) - putExtra(Intent.EXTRA_TEXT, safeBody) - } - } - // Screenshot and/or log file - else -> { - Intent(Intent.ACTION_SEND_MULTIPLE).apply { - putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient)) - putExtra(Intent.EXTRA_SUBJECT, subject) - putExtra(Intent.EXTRA_TEXT, safeBody) - putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(attachmentUris)) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - type = "message/rfc822" - } - } - } - } + suspend fun getLogUri( + context: Context, + logContent: String?, + ): Uri? = + withContext(Dispatchers.IO) { + when { + logContent.isNullOrEmpty() -> { + null + } + + else -> { + try { + val logsDir = File(context.filesDir, LOGS_DIR).apply { mkdirs() } + val timestamp = + SimpleDateFormat( + "yyyy-MM-dd_HH-mm-ss", + Locale.getDefault(), + ).format(Date()) + val filename = "Feedback Log $timestamp.txt" + val logFile = File(logsDir, filename) + logFile.writeText(logContent) + context.fileProviderUriFor(logFile) + } catch (e: Exception) { + log.error(context.getString(R.string.msg_file_creation_failed), e) + null + } + } + } + } + + fun prepareEmailIntent( + screenshotUri: Uri?, + logContentUri: Uri?, + emailRecipient: String, + subject: String, + body: String, + ): Intent { + val attachmentUris = mutableListOf() + screenshotUri?.let { attachmentUris.add(it) } + logContentUri?.let { attachmentUris.add(it) } + + return getIntentBasedOnAttachments( + emailRecipient = emailRecipient, + subject = subject, + body = body, + attachmentUris = attachmentUris, + hasLogAttachment = logContentUri != null, + ) + } + + fun getIntentBasedOnAttachments( + emailRecipient: String, + subject: String, + body: String, + attachmentUris: MutableList, + hasLogAttachment: Boolean = false, + ): Intent { + val safeBody = sanitizeEmailBody(body, hasLogAttachment) + return when { + // No screenshot or log file (if both files failed to be created) + attachmentUris.isEmpty() -> { + Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:".toUri() + putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, safeBody) + } + } + // Screenshot and/or log file + else -> { + Intent(Intent.ACTION_SEND_MULTIPLE).apply { + putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, safeBody) + putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(attachmentUris)) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + type = "message/rfc822" + } + } + } + } } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt index 28f07dc935..b3b749e596 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt @@ -15,7 +15,6 @@ import android.view.View import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.appcompat.app.AppCompatActivity -import androidx.core.content.FileProvider import androidx.core.graphics.createBitmap import androidx.core.net.toUri import androidx.core.text.HtmlCompat @@ -41,29 +40,32 @@ object FeedbackManager { private const val EMAIL_SUPPORT = "feedback@appdevforall.org" private val logger = LoggerFactory.getLogger(FeedbackManager::class.java) - /** - * Shows the feedback dialog and handles sending feedback email. - * - * @param activity The context from which feedback is being sent - */ - fun showFeedbackDialog(activity: AppCompatActivity, logContent: String?) { - val builder = DialogUtils.newMaterialDialogBuilder(activity) + /** + * Shows the feedback dialog and handles sending feedback email. + * + * @param activity The context from which feedback is being sent + */ + fun showFeedbackDialog( + activity: AppCompatActivity, + logContent: String?, + ) { + val builder = DialogUtils.newMaterialDialogBuilder(activity) - builder - .setTitle(R.string.title_alert) - .setMessage( - HtmlCompat.fromHtml( - activity.getString(R.string.email_feedback_warning_prompt), - HtmlCompat.FROM_HTML_MODE_COMPACT, - ), - ).setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } - .setPositiveButton(android.R.string.ok) { dialog, _ -> - dialog.dismiss() - sendFeedbackWithAttachments(activity, logContent) - }.show() - } + builder + .setTitle(R.string.title_alert) + .setMessage( + HtmlCompat.fromHtml( + activity.getString(R.string.email_feedback_warning_prompt), + HtmlCompat.FROM_HTML_MODE_COMPACT, + ), + ).setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() } + .setPositiveButton(android.R.string.ok) { dialog, _ -> + dialog.dismiss() + sendFeedbackWithAttachments(activity, logContent) + }.show() + } - /** + /** * Shows a simple contact dialog as fallback when email intents fail. * Uses the same title, message, and button text as the existing contact dialog. */ @@ -92,19 +94,20 @@ object FeedbackManager { customSubject: String, metadata: String, includeScreenshot: Boolean = true, - shareActivityResultLauncher: ActivityResultLauncher? = null - ) { - val message = buildString { - append(metadata) - append( - context.getString( - R.string.feedback_device_info, - BasicBuildInfo.formatVersion(), - Build.VERSION.RELEASE, - "${Build.MANUFACTURER} ${Build.MODEL}", - ) - ) - } + shareActivityResultLauncher: ActivityResultLauncher? = null, + ) { + val message = + buildString { + append(metadata) + append( + context.getString( + R.string.feedback_device_info, + BasicBuildInfo.formatVersion(), + Build.VERSION.RELEASE, + "${Build.MANUFACTURER} ${Build.MODEL}", + ), + ) + } if (includeScreenshot) { captureScreenshot(context) { screenshotFile -> @@ -113,7 +116,7 @@ object FeedbackManager { customSubject, message, screenshotFile, - shareActivityResultLauncher + shareActivityResultLauncher, ) } } else { @@ -122,7 +125,7 @@ object FeedbackManager { customSubject, message, null, - shareActivityResultLauncher + shareActivityResultLauncher, ) } } @@ -132,51 +135,49 @@ object FeedbackManager { subject: String, message: String, attachmentFile: File?, - shareActivityResultLauncher: ActivityResultLauncher? + shareActivityResultLauncher: ActivityResultLauncher?, ) { runCatching { - val intent = if (attachmentFile != null) { - Intent(Intent.ACTION_SEND).apply { - type = "message/rfc822" - putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT)) - putExtra(Intent.EXTRA_SUBJECT, subject) - putExtra(Intent.EXTRA_TEXT, message) - - val uri = FileProvider.getUriForFile( - context, - "${context.packageName}.providers.fileprovider", - attachmentFile - ) - putExtra(Intent.EXTRA_STREAM, uri) - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - - if (context !is Activity) { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + val intent = + if (attachmentFile != null) { + Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, message) + + val uri = context.fileProviderUriFor(attachmentFile) + putExtra(Intent.EXTRA_STREAM, uri) + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + + if (context !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } - } - } else { - Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:".toUri() - putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT)) - putExtra(Intent.EXTRA_SUBJECT, subject) - putExtra(Intent.EXTRA_TEXT, message) - - if (context !is Activity) { - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } else { + Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:".toUri() + putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT)) + putExtra(Intent.EXTRA_SUBJECT, subject) + putExtra(Intent.EXTRA_TEXT, message) + + if (context !is Activity) { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } } } - } launchIntentChooser( intent, context.getString(R.string.send_feedback), context, - shareActivityResultLauncher + shareActivityResultLauncher, ) }.recoverCatching { - val fallbackIntent = Intent(Intent.ACTION_SENDTO).apply { - data = "mailto:${EMAIL_SUPPORT}?subject=${Uri.encode(subject)}&body=${Uri.encode(message)}".toUri() - } + val fallbackIntent = + Intent(Intent.ACTION_SENDTO).apply { + data = "mailto:${EMAIL_SUPPORT}?subject=${Uri.encode(subject)}&body=${Uri.encode(message)}".toUri() + } context.startActivity(fallbackIntent) }.onFailure { logger.error("Failed to send feedback with attachment", it) @@ -184,8 +185,10 @@ object FeedbackManager { } } - - fun captureScreenshot(context: Context, callback: (File?) -> Unit) { + fun captureScreenshot( + context: Context, + callback: (File?) -> Unit, + ) { val activity = context as? AppCompatActivity if (activity == null) { logger.warn("Cannot capture screenshot: Context is not an Activity") @@ -194,37 +197,36 @@ object FeedbackManager { } val rootView = activity.window.decorView.rootView - val screenshotFile = createScreenshotFile(context) ?: run { - callback(null) - return - } - captureWithPixelCopy(activity, rootView, screenshotFile, callback) - } - - - private fun createScreenshotFile(context: Context): File? { - return runCatching { - val screenshotDir = File(context.cacheDir, "screenshots").apply { - if (!exists()) mkdirs() + val screenshotFile = + createScreenshotFile(context) ?: run { + callback(null) + return } + captureWithPixelCopy(activity, rootView, screenshotFile, callback) + } + + private fun createScreenshotFile(context: Context): File? = + runCatching { + val screenshotDir = + File(context.cacheDir, "screenshots").apply { + if (!exists()) mkdirs() + } val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) File(screenshotDir, "screenshot_$timestamp.png") }.onFailure { logger.error("Failed to create screenshot file", it) }.getOrNull() - } private fun captureWithPixelCopy( activity: AppCompatActivity, rootView: View, screenshotFile: File, - callback: (File?) -> Unit + callback: (File?) -> Unit, ) { + var bitmap: Bitmap? = null - var bitmap: Bitmap? = null - - try { - bitmap = createBitmap(rootView.width, rootView.height) + try { + bitmap = createBitmap(rootView.width, rootView.height) val locationOfViewInWindow = IntArray(2) rootView.getLocationInWindow(locationOfViewInWindow) @@ -234,51 +236,54 @@ object FeedbackManager { locationOfViewInWindow[0], locationOfViewInWindow[1], locationOfViewInWindow[0] + rootView.width, - locationOfViewInWindow[1] + rootView.height + locationOfViewInWindow[1] + rootView.height, ), bitmap, { result -> if (result == PixelCopy.SUCCESS) { - activity.lifecycleScope.launch { - saveScreenshot(bitmap, screenshotFile, callback) - } - } else { - logger.error("PixelCopy failed with result code: $result") - bitmap.recycle() - callback(null) - } + activity.lifecycleScope.launch { + saveScreenshot(bitmap, screenshotFile, callback) + } + } else { + logger.error("PixelCopy failed with result code: $result") + bitmap.recycle() + callback(null) + } }, - Handler(Looper.getMainLooper()) + Handler(Looper.getMainLooper()), ) } catch (e: Exception) { logger.error("PixelCopy exception, falling back to Canvas", e) - bitmap?.recycle() - callback(null) + bitmap?.recycle() + callback(null) } } - - private suspend fun saveScreenshot(bitmap: Bitmap, file: File, callback: (File?) -> Unit) { - val result = withContext(Dispatchers.IO) { - runCatching { - FileOutputStream(file).use { out -> - bitmap.compress(Bitmap.CompressFormat.PNG, 90, out) - } - file - }.onFailure { - logger.error("Failed to save screenshot", it) - }.getOrNull() - } - bitmap.recycle() - callback(result) - } - + private suspend fun saveScreenshot( + bitmap: Bitmap, + file: File, + callback: (File?) -> Unit, + ) { + val result = + withContext(Dispatchers.IO) { + runCatching { + FileOutputStream(file).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 90, out) + } + file + }.onFailure { + logger.error("Failed to save screenshot", it) + }.getOrNull() + } + bitmap.recycle() + callback(result) + } private fun launchIntentChooser( intent: Intent, chooserTitle: String, context: Context, - shareActivityResultLauncher: ActivityResultLauncher? + shareActivityResultLauncher: ActivityResultLauncher?, ) { val chooser = Intent.createChooser(intent, chooserTitle) shareActivityResultLauncher?.launch(chooser) ?: context.startActivity(chooser) @@ -294,74 +299,76 @@ object FeedbackManager { else -> "Unknown Screen" } - private fun sendFeedbackWithAttachments( - activity: AppCompatActivity, - logContent: String? - ) { - activity.lifecycleScope.launch { - val handler = FeedbackEmailHandler(activity) + private fun sendFeedbackWithAttachments( + activity: AppCompatActivity, + logContent: String?, + ) { + activity.lifecycleScope.launch { + val handler = FeedbackEmailHandler(activity) + + val screenshotUri = handler.captureAndPrepareScreenshotUri(activity) + val logContentUri = handler.getLogUri(activity, logContent) + + val feedbackRecipient = activity.getString(R.string.feedback_email) + val feedbackSubject = + activity.getString(R.string.feedback_subject, getCurrentScreenName(activity)) + val stackTraceSection = + logContent?.trim().takeIf { it?.isNotEmpty() == true } + ?: activity.getString(R.string.feedback_stack_trace_unavailable) + val feedbackBody = + buildString { + append( + activity.getString( + R.string.feedback_device_info, + BasicBuildInfo.formatVersion(), + Build.VERSION.RELEASE, + "${Build.MANUFACTURER} ${Build.MODEL}", + ), + ) + append( + activity.getString( + R.string.feedback_message, + stackTraceSection, + ), + ) + } - val screenshotUri = handler.captureAndPrepareScreenshotUri(activity) - val logContentUri = handler.getLogUri(activity, logContent) + val emailIntent = + handler.prepareEmailIntent( + screenshotUri, + logContentUri, + feedbackRecipient, + feedbackSubject, + feedbackBody, + ) - val feedbackRecipient = activity.getString(R.string.feedback_email) - val feedbackSubject = - activity.getString(R.string.feedback_subject, getCurrentScreenName(activity)) - val stackTraceSection = - logContent?.trim().takeIf { it?.isNotEmpty() == true } - ?: activity.getString(R.string.feedback_stack_trace_unavailable) - val feedbackBody = - buildString { - append( - activity.getString( - R.string.feedback_device_info, - BasicBuildInfo.formatVersion(), - Build.VERSION.RELEASE, - "${Build.MANUFACTURER} ${Build.MODEL}", - ), - ) - append( - activity.getString( - R.string.feedback_message, - stackTraceSection, - ), - ) - } + runCatching { + activity.startActivity(emailIntent) + }.onFailure { e -> + when { + e is ActivityNotFoundException -> { + Toast.makeText(activity, R.string.no_email_apps, Toast.LENGTH_LONG).show() + } - val emailIntent = - handler.prepareEmailIntent( - screenshotUri, - logContentUri, - feedbackRecipient, - feedbackSubject, - feedbackBody, - ) + e is TransactionTooLargeException || + (e is RuntimeException && e.cause is TransactionTooLargeException) -> { + logger.error("Intent transaction failed: Data too large", e) + Toast.makeText(activity, R.string.msg_feedback_log_too_long, Toast.LENGTH_LONG).show() + } - runCatching { - activity.startActivity(emailIntent) - }.onFailure { e -> - when { - e is ActivityNotFoundException -> { - Toast.makeText(activity, R.string.no_email_apps, Toast.LENGTH_LONG).show() - } - e is TransactionTooLargeException || - (e is RuntimeException && e.cause is TransactionTooLargeException) -> { - logger.error("Intent transaction failed: Data too large", e) - Toast.makeText(activity, R.string.msg_feedback_log_too_long, Toast.LENGTH_LONG).show() - } - else -> { - logger.error("Intent transaction failed: Unknown error", e) - EventBus.getDefault().post( - ReportCaughtExceptionEvent( - throwable = e, - message = "Feedback email intent failed", - extras = mapOf("screen" to getCurrentScreenName(activity)) - ) - ) - Toast.makeText(activity, R.string.unknown_error, Toast.LENGTH_LONG).show() - } - } - } - } - } + else -> { + logger.error("Intent transaction failed: Unknown error", e) + EventBus.getDefault().post( + ReportCaughtExceptionEvent( + throwable = e, + message = "Feedback email intent failed", + extras = mapOf("screen" to getCurrentScreenName(activity)), + ), + ) + Toast.makeText(activity, R.string.unknown_error, Toast.LENGTH_LONG).show() + } + } + } + } + } } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt new file mode 100644 index 0000000000..dbce28665b --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt @@ -0,0 +1,24 @@ +package com.itsaky.androidide.utils + +import android.content.Context +import android.net.Uri +import androidx.core.content.FileProvider +import java.io.File + +const val FILE_PROVIDER_AUTHORITY_SUFFIX = "providers.fileprovider" + +/** + * This app's [androidx.core.content.FileProvider] authority for a given [packageName] - shared so + * every caller that mints or checks a `content://` Uri against it agrees on the same string, even + * callers (e.g. a ViewModel) that only hold a package name rather than a full [Context]. + */ +fun fileProviderAuthorityFor(packageName: String): String = "$packageName.$FILE_PROVIDER_AUTHORITY_SUFFIX" + +/** + * This app's [androidx.core.content.FileProvider] authority - shared so every caller that mints + * or checks a `content://` Uri against it agrees on the same string. + */ +fun Context.fileProviderAuthority(): String = fileProviderAuthorityFor(packageName) + +/** Mints a `content://` Uri for [file] via this app's [androidx.core.content.FileProvider]. */ +fun Context.fileProviderUriFor(file: File): Uri = FileProvider.getUriForFile(this, fileProviderAuthority(), file) From d7b9161a46dfe87a47fddecee967721c63f4d243 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 16 Aug 2026 09:02:17 -0700 Subject: [PATCH 06/23] ADFA-4934: Fix correctness bugs found by code review (PR #1682) Behavioral half of the review findings: - "Delete installation file after install" silently did nothing for a .cgp forwarded from ExternalFileInstallActivity: DocumentsContract.deleteDocument() only works against a real SAF DocumentsProvider (it calls a special METHOD_DELETE_DOCUMENT via ContentProvider.call(), returning true unconditionally unless an exception is thrown), and our own IDEFileProvider doesn't implement that call. Now dispatches to plain contentResolver.delete() for our own authority (confirmed via decompiling FileProvider.class that its delete() correctly deletes the mapped file) and keeps deleteDocument() for real picker-sourced Uris. Forwarded installs also no longer show the checkbox at all - there's no source worth optionally keeping, since it's our own hidden temp copy - so it's now always cleaned up. - Cold-start race: isPluginManagerAvailable()/isTemplatesFeatureAvailable() could run before IDEApplication's async setup finishes if the OS cold-starts straight into ExternalFileInstallActivity. Both are now polled briefly (up to 3s) instead of failing on the first check. - Two process-death drops: ExternalFileInstallActivity and PluginManagerActivity both only acted `if (savedInstanceState == null)`, which also (incorrectly) skips a process-death-recreated instance - the one case that most needs to reprocess the restored intent, since it lost all in-memory state. Replaced with idempotency tracked inside each ViewModel instance (survives rotation, resets on process death, matching real recreation semantics). - Rename dialog: an in-flight async name suggestion could clobber whatever the user had already started typing. - installCollection()'s own collision check was case-sensitive, bypassing findExistingCollision()'s case-insensitive matching - both now share one lookup. - A failed template install used to delete the temp file and close the screen, forcing the user to re-open the original attachment to retry. Failure now just shows an error and leaves the current dialog open. - Wired ShowTemplateNameConflict.info into the conflict dialog instead of dropping it silently (it now shows contained template names, matching the fresh-install dialog). - Hardened temp/session file naming from timestamp to UUID (collision risk under rapid concurrent opens), moved UriFileImporter.getDisplayName() onto Dispatchers.IO (was running unguarded on Main), and stopped conflating CancellationException with real copy failures (now always cleans up the partial file either way, and doesn't show a bogus error for an ordinary cancellation). - installCollection() also tried File.renameTo() as an "atomic move" - this round-tripped through on-device testing: it silently fails on this device even within the app's own private storage (a well-known Android unreliability), so a real .cgt install regressed to always failing until a copy+delete fallback was added back. Re-verified end-to-end on a physical device after each fix: fresh install, rename, overwrite, and the delete-checkbox's absence for forwarded installs all confirmed working; the retry-after-failure behavior was directly triggered and observed holding the dialog open. Co-Authored-By: Claude Sonnet 5 --- .../activities/ExternalFileInstallActivity.kt | 8 +- .../activities/ExternalFileInstallScreen.kt | 34 +- .../activities/PluginManagerActivity.kt | 39 +- .../com/itsaky/androidide/di/PluginModule.kt | 1 + .../TemplateCollectionRepositoryImpl.kt | 40 +- .../ExternalFileInstallViewModel.kt | 63 +- .../viewmodels/PluginManagerViewModel.kt | 793 ++++++++++-------- .../ExternalFileInstallViewModelTest.kt | 34 + resources/src/main/res/values/strings.xml | 2 +- 9 files changed, 613 insertions(+), 401 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt index 1b8d3a3565..7130fbc2e4 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt @@ -29,8 +29,10 @@ class ExternalFileInstallActivity : IDEActivity() { return } - if (savedInstanceState == null) { - viewModel.onReceived(this, uri) - } + // No savedInstanceState guard here: onReceived() is idempotent per ViewModel instance + // (a rotation keeps the same instance, so this is a no-op there), and calling it + // unconditionally means a process-death-recreated instance - which starts fresh and + // would otherwise never see the restored intent's data - still gets processed. + viewModel.onReceived(this, uri) } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt index 94e088bc8c..6fa92532c2 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt @@ -39,6 +39,7 @@ private sealed interface DialogUiState { data class NameConflict( val existingName: String, + val info: TemplateCollectionRepository.CollectionInfo, val tempFile: File, ) : DialogUiState @@ -70,10 +71,13 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { } is ExternalFileInstallUiEffect.ShowTemplateNameConflict -> { - dialogState = DialogUiState.NameConflict(effect.existingName, effect.tempFile) + dialogState = DialogUiState.NameConflict(effect.existingName, effect.info, effect.tempFile) } is ExternalFileInstallUiEffect.ShowError -> { + // Deliberately doesn't touch dialogState: on an install failure the ViewModel + // sends ShowError without a following Finish, so whichever dialog is open + // (install-confirm / name-conflict / rename) stays open for the user to retry. flashError(context.getString(effect.messageResId, *effect.formatArgs.toTypedArray())) } @@ -101,11 +105,9 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { overwrite = false, ), ) - dialogState = DialogUiState.None }, onDismiss = { viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile)) - dialogState = DialogUiState.None }, ) } @@ -121,12 +123,10 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { overwrite = true, ), ) - dialogState = DialogUiState.None }, onRename = { dialogState = DialogUiState.Rename(state.existingName, state.tempFile) }, onDismiss = { viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile)) - dialogState = DialogUiState.None }, ) } @@ -143,11 +143,9 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { overwrite = false, ), ) - dialogState = DialogUiState.None }, onDismiss = { viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile)) - dialogState = DialogUiState.None }, ) } @@ -196,7 +194,15 @@ private fun NameConflictDialog( AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.title_template_already_installed)) }, - text = { Text(stringResource(R.string.msg_template_name_conflict, state.existingName)) }, + text = { + Text( + stringResource( + R.string.msg_template_name_conflict, + state.existingName, + state.info.templateNames.joinToString(", "), + ), + ) + }, confirmButton = { TextButton(onClick = onOverwrite) { Text(stringResource(R.string.btn_overwrite)) } }, @@ -217,12 +223,17 @@ private fun RenameDialog( onDismiss: () -> Unit, ) { var name by remember { mutableStateOf(TextFieldValue(state.existingName)) } + var userEdited by remember { mutableStateOf(false) } var suggestionReady by remember { mutableStateOf(false) } val currentSuggestName by rememberUpdatedState(suggestName) LaunchedEffect(state.existingName) { val suggested = currentSuggestName(state.existingName) - name = TextFieldValue(suggested, selection = TextRange(suggested.length)) + // Only apply the suggestion if the user hasn't already started typing their own name - + // this resolves asynchronously and must not clobber in-progress input. + if (!userEdited) { + name = TextFieldValue(suggested, selection = TextRange(suggested.length)) + } suggestionReady = true } @@ -232,7 +243,10 @@ private fun RenameDialog( text = { OutlinedTextField( value = name, - onValueChange = { name = it }, + onValueChange = { + name = it + userEdited = true + }, label = { Text(stringResource(R.string.hint_new_template_collection_name)) }, singleLine = true, ) diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index 5c3236f119..1055b8c9f0 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -109,11 +109,19 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { setupFeedbackButton() observeViewModel() - if (savedInstanceState == null) { - IntentCompat - .getParcelableExtra(intent, EXTRA_PENDING_INSTALL_URI, Uri::class.java) - ?.let { showInstallConfirmation(it) } - } + // No savedInstanceState guard: markPendingInstallUriHandled() is the idempotency + // check, scoped to the ViewModel instance rather than the Activity's recreation + // reason - it survives rotation (skips a duplicate dialog there) but resets on + // process death (a fresh ViewModel is created), so a process-death-recreated + // instance still shows the dialog instead of silently dropping the forwarded + // install. The intent's extra itself is preserved across both cases by the OS. + IntentCompat + .getParcelableExtra(intent, EXTRA_PENDING_INSTALL_URI, Uri::class.java) + ?.let { uri -> + if (viewModel.markPendingInstallUriHandled(uri)) { + showInstallConfirmation(uri, forceDeleteSource = true) + } + } } catch (e: Exception) { // Log the error and finish the activity if something goes wrong e.printStackTrace() @@ -311,7 +319,26 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { private fun Uri.isSupportedPluginFile(): Boolean = getFileName(this@PluginManagerActivity).endsWith(PLUGIN_EXTENSION, ignoreCase = true) - private fun showInstallConfirmation(uri: Uri) { + /** + * @param forceDeleteSource When true (a `.cgp` forwarded from + * [ExternalFileInstallActivity]), [uri] is our own hidden temp copy, not a file the user + * picked - there's no source worth keeping, so the "delete source" checkbox is skipped + * entirely and the temp file is always cleaned up. + */ + private fun showInstallConfirmation( + uri: Uri, + forceDeleteSource: Boolean = false, + ) { + if (forceDeleteSource) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.title_install_plugin) + .setPositiveButton(R.string.btn_install) { _, _ -> + viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(uri, deleteSourceAfterInstall = true)) + }.setNegativeButton(android.R.string.cancel, null) + .show() + return + } + val dialogView = layoutInflater.inflate(R.layout.dialog_install_plugin, null) val deleteCheckBox = dialogView.findViewById(R.id.checkbox_delete_source) diff --git a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt index fc3a7b0e0a..656738da19 100644 --- a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt @@ -36,6 +36,7 @@ val pluginModule = pluginRepository = get(), contentResolver = androidContext().contentResolver, filesDir = androidContext().filesDir, + packageName = androidContext().packageName, ) } diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt index 30aa8784b3..1a06a3ce7b 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -19,6 +19,15 @@ import java.io.File class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { private companion object { private const val TAG = "TemplateCollectionRepository" + + /** Case-insensitive match by base filename - the only stable "collection identity" available. */ + private fun findCollisionFile( + templatesDir: File, + baseName: String, + ): File? = + templatesDir + .listFiles { file -> file.extension == TEMPLATE_ARCHIVE_EXTENSION } + ?.firstOrNull { it.nameWithoutExtension.equals(baseName, ignoreCase = true) } } override suspend fun inspectCollection(candidateFile: File): Result = @@ -45,10 +54,7 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { override suspend fun findExistingCollision(baseName: String): String? = withContext(Dispatchers.IO) { - Environment.TEMPLATES_DIR - ?.listFiles { file -> file.extension == TEMPLATE_ARCHIVE_EXTENSION } - ?.firstOrNull { it.nameWithoutExtension.equals(baseName, ignoreCase = true) } - ?.nameWithoutExtension + Environment.TEMPLATES_DIR?.let { findCollisionFile(it, baseName) }?.nameWithoutExtension } override suspend fun installCollection( @@ -62,15 +68,33 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { Environment.TEMPLATES_DIR ?: throw IllegalStateException("Templates system not available") - val destFile = File(templatesDir, "$targetBaseName.$TEMPLATE_ARCHIVE_EXTENSION") - if (destFile.exists() && !overwrite) { + // Reuse the same case-insensitive lookup findExistingCollision() uses, so a + // case-variant match (e.g. installing "mytemplates" when "MyTemplates.cgt" is + // already there) is caught here too instead of silently creating a duplicate. + val existingMatch = findCollisionFile(templatesDir, targetBaseName) + if (existingMatch != null && !overwrite) { throw IllegalStateException( "A template collection named \"$targetBaseName\" already exists", ) } - candidateFile.copyTo(destFile, overwrite = true) - candidateFile.delete() + // Overwrite the existing case-variant file in place (preserving its casing) + // rather than create a second, case-differing duplicate. + val destFile = existingMatch ?: File(templatesDir, "$targetBaseName.$TEMPLATE_ARCHIVE_EXTENSION") + if (destFile.exists() && !destFile.delete()) { + throw IllegalStateException("Failed to replace existing file: ${destFile.name}") + } + + // Try an atomic move first; File.renameTo() is unreliable on Android even within + // the same app's private storage (confirmed on a physical device: it silently + // fails here despite temp/ and templates/ both being under filesDir), so fall + // back to copy+delete rather than trust it unconditionally. + if (!candidateFile.renameTo(destFile)) { + candidateFile.copyTo(destFile, overwrite = true) + if (!candidateFile.delete()) { + Log.w(TAG, "Installed but failed to delete source temp file: ${candidateFile.absolutePath}") + } + } ITemplateProvider.getInstance(reload = true) Unit diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index dc3d82162d..1fb053146b 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -14,14 +14,18 @@ import com.itsaky.androidide.resources.R import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent import com.itsaky.androidide.utils.UriFileImporter +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import java.io.File +import java.util.UUID /** * Handles a `.cgp`/`.cgt` file opened from outside the app (e.g. an email attachment), backing @@ -36,6 +40,13 @@ class ExternalFileInstallViewModel( private companion object { private const val TAG = "ExternalFileInstallVM" private val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") + + // A cold OS-triggered launch of this activity can win the race against + // IDEApplication's async setup (device-unlock -> CredentialProtectedApplicationLoader.load()), + // so isPluginManagerAvailable()/isTemplatesFeatureAvailable() are polled briefly + // instead of failing on the very first check. + private const val SETUP_WAIT_ATTEMPTS = 10 + private const val SETUP_WAIT_INTERVAL_MS = 300L } // Buffered (not rendezvous): onReceived() runs via Dispatchers.Main.immediate right after @@ -45,13 +56,22 @@ class ExternalFileInstallViewModel( private val _uiEffect = Channel(capacity = Channel.BUFFERED) val uiEffect = _uiEffect.receiveAsFlow() + // onReceived() must run exactly once per ViewModel instance: this instance survives a + // rotation (so a duplicate call there is a no-op, not a re-processed intent), but is + // recreated fresh by Koin after process death (so the fresh instance still processes the + // restored intent instead of the call being skipped entirely). + private var received = false + /** Call once, from `Activity.onCreate()`, with the VIEW intent's data [Uri]. */ fun onReceived( context: Context, uri: Uri, ) { + if (received) return + received = true + viewModelScope.launch { - val displayName = UriFileImporter.getDisplayName(contentResolver, uri) + val displayName = withContext(Dispatchers.IO) { UriFileImporter.getDisplayName(contentResolver, uri) } val extension = displayName?.substringAfterLast('.', "")?.lowercase() if (displayName.isNullOrBlank() || extension.isNullOrBlank()) { @@ -64,28 +84,37 @@ class ExternalFileInstallViewModel( return@launch } - if (extension == PLUGIN_ARCHIVE_EXTENSION && !pluginRepository.isPluginManagerAvailable()) { + if (extension == PLUGIN_ARCHIVE_EXTENSION && + !awaitAvailable(pluginRepository::isPluginManagerAvailable) + ) { sendErrorAndFinish(R.string.msg_ide_setup_incomplete) return@launch } - if (extension == TEMPLATE_ARCHIVE_EXTENSION && !templateCollectionRepository.isTemplatesFeatureAvailable()) { + if (extension == TEMPLATE_ARCHIVE_EXTENSION && + !awaitAvailable(templateCollectionRepository::isTemplatesFeatureAvailable) + ) { sendErrorAndFinish(R.string.msg_ide_setup_incomplete) return@launch } + val tempDir = File(filesDir, "temp").apply { mkdirs() } + val destination = File(tempDir, "incoming_${UUID.randomUUID()}.$extension") + val tempFile = try { withContext(Dispatchers.IO) { - val tempDir = File(filesDir, "temp").apply { mkdirs() } - val destination = File(tempDir, "incoming_${System.currentTimeMillis()}.$extension") UriFileImporter.copyUriToFile(contentResolver, uri, destination) { IllegalStateException("Cannot open file") } destination } + } catch (e: CancellationException) { + withContext(NonCancellable + Dispatchers.IO) { deleteQuietlyBlocking(destination) } + throw e } catch (e: Exception) { Log.e(TAG, "Failed to copy incoming file", e) + withContext(Dispatchers.IO) { deleteQuietlyBlocking(destination) } sendErrorAndFinish(R.string.msg_invalid_incoming_file) return@launch } @@ -101,6 +130,14 @@ class ExternalFileInstallViewModel( } } + private suspend fun awaitAvailable(check: () -> Boolean): Boolean { + repeat(SETUP_WAIT_ATTEMPTS) { attempt -> + if (check()) return true + if (attempt < SETUP_WAIT_ATTEMPTS - 1) delay(SETUP_WAIT_INTERVAL_MS) + } + return false + } + private suspend fun dispatchTemplateInstall( tempFile: File, baseName: String, @@ -152,9 +189,11 @@ class ExternalFileInstallViewModel( _uiEffect.trySend(ExternalFileInstallUiEffect.ShowSuccess(R.string.msg_template_installed)) _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) }.onFailure { exception -> + // Deliberately don't delete tempFile or Finish here: the dialog the user was + // just on (install-confirm / name-conflict / rename) stays open so they can + // retry - e.g. pick a different name after a collision, or Overwrite instead. Log.e(TAG, "Failed to install template collection", exception) - deleteQuietly(tempFile) - sendErrorAndFinish(R.string.msg_template_install_failed) + _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(R.string.msg_template_install_failed)) } } } @@ -180,10 +219,12 @@ class ExternalFileInstallViewModel( } private suspend fun deleteQuietly(file: File) { - withContext(Dispatchers.IO) { - if (file.exists()) { - file.delete() - } + withContext(Dispatchers.IO) { deleteQuietlyBlocking(file) } + } + + private fun deleteQuietlyBlocking(file: File) { + if (file.exists()) { + file.delete() } } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index 24043b5f46..da445b622b 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -15,6 +15,7 @@ import com.itsaky.androidide.ui.models.PluginManagerUiState import com.itsaky.androidide.ui.models.PluginOperation import com.itsaky.androidide.utils.EditorDecorationBridge import com.itsaky.androidide.utils.UriFileImporter +import com.itsaky.androidide.utils.fileProviderAuthorityFor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -25,373 +26,441 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File +import java.util.UUID /** * ViewModel for the Plugin Manager screen * Manages UI state and business logic using MVVM pattern */ class PluginManagerViewModel( - private val pluginRepository: PluginRepository, - private val contentResolver: ContentResolver, - private val filesDir: File + private val pluginRepository: PluginRepository, + private val contentResolver: ContentResolver, + private val filesDir: File, + packageName: String, ) : ViewModel() { - - private companion object { - private const val TAG = "PluginManagerViewModel" - } - - // Mutable state for internal updates - private val _uiState = MutableStateFlow( - PluginManagerUiState( - isPluginManagerAvailable = pluginRepository.isPluginManagerAvailable() - ) - ) - - // Public read-only state - val uiState: StateFlow = _uiState.asStateFlow() - - // Channel for one-time UI effects - private val _uiEffect = Channel() - val uiEffect = _uiEffect.receiveAsFlow() - - // Current operation tracking - private val _currentOperation = MutableStateFlow(PluginOperation.None) - val currentOperation: StateFlow = _currentOperation.asStateFlow() - - init { - loadPlugins() - } - - /** - * Handle UI events - */ - fun onEvent(event: PluginManagerUiEvent) { - when (event) { - is PluginManagerUiEvent.LoadPlugins -> loadPlugins() - is PluginManagerUiEvent.EnablePlugin -> enablePlugin(event.pluginId) - is PluginManagerUiEvent.DisablePlugin -> disablePlugin(event.pluginId) - is PluginManagerUiEvent.UninstallPlugin -> showUninstallConfirmation(event.pluginId) - is PluginManagerUiEvent.InstallPlugin -> installPlugin( - event.uri, - event.deleteSourceAfterInstall - ) - is PluginManagerUiEvent.ConfirmOverwrite -> installPlugin( - event.uri, - event.deleteSourceAfterInstall, - checkConflict = false - ) - - is PluginManagerUiEvent.OpenFilePicker -> openFilePicker() - is PluginManagerUiEvent.ShowPluginDetails -> showPluginDetails(event.plugin) - } - } - - /** - * Load all plugins - */ - private fun loadPlugins() { - if (!pluginRepository.isPluginManagerAvailable()) { - _uiState.update { it.copy(isPluginManagerAvailable = false) } - return - } - - viewModelScope.launch { - _currentOperation.value = PluginOperation.Loading - _uiState.update { it.copy(isLoading = true) } - - pluginRepository.getAllPlugins() - .onSuccess { plugins -> - Log.d(TAG, "Loaded ${plugins.size} plugins") - _uiState.update { - it.copy( - isLoading = false, - plugins = plugins, - isPluginManagerAvailable = true - ) - } - } - .onFailure { exception -> - Log.e(TAG, "Failed to load plugins", exception) - _uiState.update { - it.copy(isLoading = false) - } - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_load_failed, - listOf(exception.message ?: "") - ) - ) - } - - // Keep the editor decoration providers in sync with the enabled plugin set. - EditorDecorationBridge.refresh() - - _currentOperation.value = PluginOperation.None - } - } - - /** - * Enable a plugin - */ - private fun enablePlugin(pluginId: String) { - viewModelScope.launch { - _currentOperation.value = PluginOperation.Enabling(pluginId) - - pluginRepository.enablePlugin(pluginId) - .onSuccess { success -> - if (success) { - Log.d(TAG, "Plugin enabled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_enabled)) - loadPlugins() - } else { - Log.w(TAG, "Failed to enable plugin: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_enable_failed)) - } - } - .onFailure { exception -> - Log.e(TAG, "Error enabling plugin: $pluginId", exception) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_enable_error, - listOf(exception.message ?: "") - ) - ) - } - - _currentOperation.value = PluginOperation.None - } - } - - /** - * Disable a plugin - */ - private fun disablePlugin(pluginId: String) { - viewModelScope.launch { - _currentOperation.value = PluginOperation.Disabling(pluginId) - - pluginRepository.disablePlugin(pluginId) - .onSuccess { success -> - if (success) { - Log.d(TAG, "Plugin disabled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_disabled)) - loadPlugins() - } else { - Log.w(TAG, "Failed to disable plugin: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_disable_failed)) - } - } - .onFailure { exception -> - Log.e(TAG, "Error disabling plugin: $pluginId", exception) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_disable_error, - listOf(exception.message ?: "") - ) - ) - } - - _currentOperation.value = PluginOperation.None - } - } - - /** - * Show uninstall confirmation dialog - */ - private fun showUninstallConfirmation(pluginId: String) { - val plugin = _uiState.value.plugins.find { it.metadata.id == pluginId } - if (plugin != null) { - viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.ShowUninstallConfirmation(plugin)) - } - } - } - - /** - * Uninstall a plugin (called after confirmation) - */ - fun confirmUninstallPlugin(pluginId: String) { - viewModelScope.launch { - _currentOperation.value = PluginOperation.Uninstalling(pluginId) - - pluginRepository.uninstallPlugin(pluginId) - .onSuccess { success -> - if (success) { - Log.d(TAG, "Plugin uninstalled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_uninstalled)) - loadPlugins() - _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) - } else { - Log.w(TAG, "Failed to uninstall plugin: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_uninstall_failed)) - } - } - .onFailure { exception -> - Log.e(TAG, "Error uninstalling plugin: $pluginId", exception) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_uninstall_error, - listOf(exception.message ?: "") - ) - ) - } - - _currentOperation.value = PluginOperation.None - } - } - - private fun installPlugin(uri: Uri, deleteSourceAfterInstall: Boolean, checkConflict: Boolean = true) { - viewModelScope.launch { - _currentOperation.value = PluginOperation.Installing - _uiState.update { it.copy(isInstalling = true) } - - var tempFile: File? = null - - try { - tempFile = withContext(Dispatchers.IO) { - val fileName = UriFileImporter.getDisplayName(contentResolver, uri) - val extension = if (fileName?.endsWith( - ".cgp", - ignoreCase = true - ) == true - ) ".cgp" else ".apk" - val tempFileName = "temp_plugin_${System.currentTimeMillis()}$extension" - val tempDir = File(filesDir, "temp").apply { mkdirs() } - val tempFile = File(tempDir, tempFileName) - - UriFileImporter.copyUriToFile(contentResolver, uri, tempFile) { - Exception("Cannot open file") - } - tempFile - } - - if (checkConflict && resolveInstallConflict(tempFile, uri, deleteSourceAfterInstall)) { - return@launch - } - - pluginRepository.installPluginFromFile(tempFile) - .onSuccess { - Log.d(TAG, "Plugin installed successfully") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) - loadPlugins() - _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) - - if (deleteSourceAfterInstall) { - deleteSourceDocument(uri) - } - } - .onFailure { exception -> - Log.e(TAG, "Failed to install plugin", exception) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_install_failed, - listOf(exception.message ?: "") - ) - ) - } - } catch (exception: Exception) { - Log.e(TAG, "Error installing plugin from URI", exception) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_install_failed, - listOf(exception.message ?: "") - ) - ) - } finally { - tempFile?.let { file -> - withContext(Dispatchers.IO) { - if (file.exists()) { - file.delete() - } - } - } - _uiState.update { it.copy(isInstalling = false) } - _currentOperation.value = PluginOperation.None - } - } - } - - private suspend fun resolveInstallConflict( - tempFile: File, - uri: Uri, - deleteSourceAfterInstall: Boolean - ): Boolean { - val incoming = pluginRepository.getPluginMetadataFromFile(tempFile).getOrNull() - if (incoming == null) { - Log.w(TAG, "Failed to read plugin metadata from ${tempFile.name}; aborting install") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file)) - return true - } - - val existing = _uiState.value.plugins.find { it.metadata.id == incoming.id } - ?: return false - - val signaturesMatch = pluginRepository - .haveMatchingSignatures(tempFile, existing.metadata.id) - .getOrDefault(false) - - val effect = if (!signaturesMatch) { - PluginManagerUiEffect.ShowError( - R.string.msg_plugin_signature_mismatch, - listOf(existing.metadata.name) - ) - } else { - PluginManagerUiEffect.ShowOverwriteConfirmation( - existing = existing, - incomingMetadata = incoming, - uri = uri, - deleteSourceAfterInstall = deleteSourceAfterInstall - ) - } - _uiEffect.trySend(effect) - return true - } - - private suspend fun deleteSourceDocument(uri: Uri) { - withContext(Dispatchers.IO) { - try { - val deleted = DocumentsContract.deleteDocument(contentResolver, uri) - if (!deleted) { - _uiEffect.trySend( - PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed) - ) - } - } catch (e: Exception) { - Log.w(TAG, "Failed to delete source document", e) - _uiEffect.trySend( - PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed) - ) - } - } - } - - /** - * Open file picker - */ - private fun openFilePicker() { - viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.OpenFilePicker) - } - } - - /** - * Show plugin details - */ - private fun showPluginDetails(plugin: PluginInfo) { - viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.ShowPluginDetails(plugin)) - } - } - - /** - * Check if a specific plugin operation is in progress - */ - fun isPluginOperationInProgress(pluginId: String): Boolean { - return when (val operation = _currentOperation.value) { - is PluginOperation.Enabling -> operation.pluginId == pluginId - is PluginOperation.Disabling -> operation.pluginId == pluginId - is PluginOperation.Uninstalling -> operation.pluginId == pluginId - else -> false - } - } - + private companion object { + private const val TAG = "PluginManagerViewModel" + } + + private val fileProviderAuthority = fileProviderAuthorityFor(packageName) + + // Tracks the last forwarded-install Uri (from ExternalFileInstallActivity) this instance has + // already shown a dialog for. Survives rotation (same ViewModel instance, via the + // ViewModelStore) so the dialog isn't re-popped on every rotation, but resets on process + // death (a fresh instance is created), so a process-death-recreated PluginManagerActivity + // still shows the dialog instead of silently dropping the forwarded install. + private var handledPendingInstallUri: Uri? = null + + /** + * Returns true the first time [uri] is seen by this ViewModel instance - see + * [handledPendingInstallUri] for why this, rather than an Activity `savedInstanceState` + * check, is what correctly distinguishes "already shown after a rotation" from "never shown + * because the process died". + */ + fun markPendingInstallUriHandled(uri: Uri): Boolean { + if (handledPendingInstallUri == uri) return false + handledPendingInstallUri = uri + return true + } + + // Mutable state for internal updates + private val _uiState = + MutableStateFlow( + PluginManagerUiState( + isPluginManagerAvailable = pluginRepository.isPluginManagerAvailable(), + ), + ) + + // Public read-only state + val uiState: StateFlow = _uiState.asStateFlow() + + // Channel for one-time UI effects + private val _uiEffect = Channel() + val uiEffect = _uiEffect.receiveAsFlow() + + // Current operation tracking + private val _currentOperation = MutableStateFlow(PluginOperation.None) + val currentOperation: StateFlow = _currentOperation.asStateFlow() + + init { + loadPlugins() + } + + /** + * Handle UI events + */ + fun onEvent(event: PluginManagerUiEvent) { + when (event) { + is PluginManagerUiEvent.LoadPlugins -> { + loadPlugins() + } + + is PluginManagerUiEvent.EnablePlugin -> { + enablePlugin(event.pluginId) + } + + is PluginManagerUiEvent.DisablePlugin -> { + disablePlugin(event.pluginId) + } + + is PluginManagerUiEvent.UninstallPlugin -> { + showUninstallConfirmation(event.pluginId) + } + + is PluginManagerUiEvent.InstallPlugin -> { + installPlugin( + event.uri, + event.deleteSourceAfterInstall, + ) + } + + is PluginManagerUiEvent.ConfirmOverwrite -> { + installPlugin( + event.uri, + event.deleteSourceAfterInstall, + checkConflict = false, + ) + } + + is PluginManagerUiEvent.OpenFilePicker -> { + openFilePicker() + } + + is PluginManagerUiEvent.ShowPluginDetails -> { + showPluginDetails(event.plugin) + } + } + } + + /** + * Load all plugins + */ + private fun loadPlugins() { + if (!pluginRepository.isPluginManagerAvailable()) { + _uiState.update { it.copy(isPluginManagerAvailable = false) } + return + } + + viewModelScope.launch { + _currentOperation.value = PluginOperation.Loading + _uiState.update { it.copy(isLoading = true) } + + pluginRepository + .getAllPlugins() + .onSuccess { plugins -> + Log.d(TAG, "Loaded ${plugins.size} plugins") + _uiState.update { + it.copy( + isLoading = false, + plugins = plugins, + isPluginManagerAvailable = true, + ) + } + }.onFailure { exception -> + Log.e(TAG, "Failed to load plugins", exception) + _uiState.update { + it.copy(isLoading = false) + } + _uiEffect.trySend( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_load_failed, + listOf(exception.message ?: ""), + ), + ) + } + + // Keep the editor decoration providers in sync with the enabled plugin set. + EditorDecorationBridge.refresh() + + _currentOperation.value = PluginOperation.None + } + } + + /** + * Enable a plugin + */ + private fun enablePlugin(pluginId: String) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Enabling(pluginId) + + pluginRepository + .enablePlugin(pluginId) + .onSuccess { success -> + if (success) { + Log.d(TAG, "Plugin enabled successfully: $pluginId") + _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_enabled)) + loadPlugins() + } else { + Log.w(TAG, "Failed to enable plugin: $pluginId") + _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_enable_failed)) + } + }.onFailure { exception -> + Log.e(TAG, "Error enabling plugin: $pluginId", exception) + _uiEffect.trySend( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_enable_error, + listOf(exception.message ?: ""), + ), + ) + } + + _currentOperation.value = PluginOperation.None + } + } + + /** + * Disable a plugin + */ + private fun disablePlugin(pluginId: String) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Disabling(pluginId) + + pluginRepository + .disablePlugin(pluginId) + .onSuccess { success -> + if (success) { + Log.d(TAG, "Plugin disabled successfully: $pluginId") + _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_disabled)) + loadPlugins() + } else { + Log.w(TAG, "Failed to disable plugin: $pluginId") + _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_disable_failed)) + } + }.onFailure { exception -> + Log.e(TAG, "Error disabling plugin: $pluginId", exception) + _uiEffect.trySend( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_disable_error, + listOf(exception.message ?: ""), + ), + ) + } + + _currentOperation.value = PluginOperation.None + } + } + + /** + * Show uninstall confirmation dialog + */ + private fun showUninstallConfirmation(pluginId: String) { + val plugin = _uiState.value.plugins.find { it.metadata.id == pluginId } + if (plugin != null) { + viewModelScope.launch { + _uiEffect.trySend(PluginManagerUiEffect.ShowUninstallConfirmation(plugin)) + } + } + } + + /** + * Uninstall a plugin (called after confirmation) + */ + fun confirmUninstallPlugin(pluginId: String) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Uninstalling(pluginId) + + pluginRepository + .uninstallPlugin(pluginId) + .onSuccess { success -> + if (success) { + Log.d(TAG, "Plugin uninstalled successfully: $pluginId") + _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_uninstalled)) + loadPlugins() + _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) + } else { + Log.w(TAG, "Failed to uninstall plugin: $pluginId") + _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_uninstall_failed)) + } + }.onFailure { exception -> + Log.e(TAG, "Error uninstalling plugin: $pluginId", exception) + _uiEffect.trySend( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_uninstall_error, + listOf(exception.message ?: ""), + ), + ) + } + + _currentOperation.value = PluginOperation.None + } + } + + private fun installPlugin( + uri: Uri, + deleteSourceAfterInstall: Boolean, + checkConflict: Boolean = true, + ) { + viewModelScope.launch { + _currentOperation.value = PluginOperation.Installing + _uiState.update { it.copy(isInstalling = true) } + + var tempFile: File? = null + + try { + tempFile = + withContext(Dispatchers.IO) { + val fileName = UriFileImporter.getDisplayName(contentResolver, uri) + val extension = + if (fileName?.endsWith( + ".cgp", + ignoreCase = true, + ) == true + ) { + ".cgp" + } else { + ".apk" + } + val tempFileName = "temp_plugin_${UUID.randomUUID()}$extension" + val tempDir = File(filesDir, "temp").apply { mkdirs() } + val tempFile = File(tempDir, tempFileName) + + UriFileImporter.copyUriToFile(contentResolver, uri, tempFile) { + Exception("Cannot open file") + } + tempFile + } + + if (checkConflict && resolveInstallConflict(tempFile, uri, deleteSourceAfterInstall)) { + return@launch + } + + pluginRepository + .installPluginFromFile(tempFile) + .onSuccess { + Log.d(TAG, "Plugin installed successfully") + _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) + loadPlugins() + _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) + + if (deleteSourceAfterInstall) { + deleteSourceDocument(uri) + } + }.onFailure { exception -> + Log.e(TAG, "Failed to install plugin", exception) + _uiEffect.trySend( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_install_failed, + listOf(exception.message ?: ""), + ), + ) + } + } catch (exception: Exception) { + Log.e(TAG, "Error installing plugin from URI", exception) + _uiEffect.trySend( + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_install_failed, + listOf(exception.message ?: ""), + ), + ) + } finally { + tempFile?.let { file -> + withContext(Dispatchers.IO) { + if (file.exists()) { + file.delete() + } + } + } + _uiState.update { it.copy(isInstalling = false) } + _currentOperation.value = PluginOperation.None + } + } + } + + private suspend fun resolveInstallConflict( + tempFile: File, + uri: Uri, + deleteSourceAfterInstall: Boolean, + ): Boolean { + val incoming = pluginRepository.getPluginMetadataFromFile(tempFile).getOrNull() + if (incoming == null) { + Log.w(TAG, "Failed to read plugin metadata from ${tempFile.name}; aborting install") + _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file)) + return true + } + + val existing = + _uiState.value.plugins.find { it.metadata.id == incoming.id } + ?: return false + + val signaturesMatch = + pluginRepository + .haveMatchingSignatures(tempFile, existing.metadata.id) + .getOrDefault(false) + + val effect = + if (!signaturesMatch) { + PluginManagerUiEffect.ShowError( + R.string.msg_plugin_signature_mismatch, + listOf(existing.metadata.name), + ) + } else { + PluginManagerUiEffect.ShowOverwriteConfirmation( + existing = existing, + incomingMetadata = incoming, + uri = uri, + deleteSourceAfterInstall = deleteSourceAfterInstall, + ) + } + _uiEffect.trySend(effect) + return true + } + + private suspend fun deleteSourceDocument(uri: Uri) { + withContext(Dispatchers.IO) { + try { + // DocumentsContract.deleteDocument() only works against a real SAF + // DocumentsProvider: it calls the provider's special METHOD_DELETE_DOCUMENT via + // ContentProvider.call(), and returns true unconditionally unless an exception is + // thrown. Our own IDEFileProvider (used for a .cgp forwarded from + // ExternalFileInstallActivity) doesn't implement that call - so deleteDocument() + // against one of its Uris silently "succeeds" without deleting anything. FileProvider + // does properly implement plain delete(), so use that for our own authority instead. + val deleted = + if (uri.authority == fileProviderAuthority) { + contentResolver.delete(uri, null, null) > 0 + } else { + DocumentsContract.deleteDocument(contentResolver, uri) + } + if (!deleted) { + _uiEffect.trySend( + PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), + ) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to delete source document", e) + _uiEffect.trySend( + PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), + ) + } + } + } + + /** + * Open file picker + */ + private fun openFilePicker() { + viewModelScope.launch { + _uiEffect.trySend(PluginManagerUiEffect.OpenFilePicker) + } + } + + /** + * Show plugin details + */ + private fun showPluginDetails(plugin: PluginInfo) { + viewModelScope.launch { + _uiEffect.trySend(PluginManagerUiEffect.ShowPluginDetails(plugin)) + } + } + + /** + * Check if a specific plugin operation is in progress + */ + fun isPluginOperationInProgress(pluginId: String): Boolean = + when (val operation = _currentOperation.value) { + is PluginOperation.Enabling -> operation.pluginId == pluginId + is PluginOperation.Disabling -> operation.pluginId == pluginId + is PluginOperation.Uninstalling -> operation.pluginId == pluginId + else -> false + } } diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt index 4eaa834d62..74b70d71a3 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt @@ -12,9 +12,11 @@ import com.itsaky.androidide.viewmodel.MainDispatcherRule import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -52,6 +54,13 @@ class ExternalFileInstallViewModelTest { ) } + @After + fun tearDown() { + // onReceived() copies into context.filesDir/temp - a real Robolectric app files dir, not + // covered by the tempFolder rule above, so it doesn't get cleaned up automatically. + File(context.filesDir, "temp").deleteRecursively() + } + private fun sourceUriFor( fileName: String, content: String = "dummy", @@ -148,6 +157,31 @@ class ExternalFileInstallViewModelTest { assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) } + @Test + fun `onReceived is idempotent per ViewModel instance`() = + runTest { + stubPluginManagerAvailable(true) + val uri = sourceUriFor("my-plugin.cgp") + + viewModel.onReceived(context, uri) + viewModel.onReceived(context, uri) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java) + verify(exactly = 1) { pluginRepository.isPluginManagerAvailable() } + } + + @Test + fun `plugin manager becoming available mid-retry still forwards`() = + runTest { + every { pluginRepository.isPluginManagerAvailable() } returnsMany listOf(false, false, true) + + viewModel.onReceived(context, sourceUriFor("my-plugin.cgp")) + + val first = viewModel.uiEffect.first() + assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java) + } + @Test fun `sanitizeBaseName strips filesystem-unsafe characters`() { assertThat(viewModel.sanitizeBaseName("my:templates/v2")).isEqualTo("my_templates_v2") diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 305eeb0cb6..771598e7a5 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1095,7 +1095,7 @@ Install Template Collection Install \'%1$s\' with the following templates: %2$s? Template Collection Already Installed - A template collection named \'%1$s\' is already installed. What would you like to do? + A template collection named \'%1$s\' is already installed. The new one contains: %2$s. What would you like to do? Overwrite Rename & Install New collection name From 859365eb89693b9c5fc2ebe152380993ed2eb863 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 16 Aug 2026 10:52:11 -0700 Subject: [PATCH 07/23] ADFA-4934: Fix correctness/reuse findings from max-effort code review Correctness: - TemplateCollectionRepositoryImpl: refuse to install/overwrite the reserved "core" basename so an external .cgt named "core" can no longer delete the bundled default templates archive; wrap findExistingCollision() in try/catch like its siblings. - ExternalFileInstallViewModel: guard confirmTemplateInstall() against a double-tap race; give the Flashbar entrance animation time to render before Finish tears the activity down. - Add uiMode/locale/fontScale/density to both activities' configChanges so a config change mid-dialog can't strand the forwarded-install flow behind a one-shot guard that already fired. - PluginManagerViewModel: clean up the forwarded source file on install failure, a conflict abort, or the user cancelling either confirmation dialog - not just on success. Reuse/simplification: - Forward a .cgp as a plain file path instead of a minted FileProvider Uri, so PluginManagerViewModel can install directly from it instead of copying it a second time; drops the now-redundant IDEFileProvider.getUriForFile wrapper. - Replace Uri-authority sniffing in deleteSourceDocument() with an explicit PluginInstallSource (ContentUri vs LocalFile) from the caller. - Extract a shared LastValueGate for the two "run at most once per forwarded value" guards that were previously duplicated with slightly different shapes. - Dedupe the template-name joinToString() formatting between dialogs. - Wire long-press help into ExternalFileInstallScreen.kt via a small reusable Compose/idetooltips interop helper, per ADR 0009/REVIEW.md guidance for a first Compose screen ahead of the ADFA-4381 bridge. Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 12 +- .../activities/ExternalFileInstallActivity.kt | 2 +- .../activities/ExternalFileInstallScreen.kt | 47 ++++- .../activities/PluginManagerActivity.kt | 48 +++-- .../com/itsaky/androidide/di/PluginModule.kt | 1 - .../androidide/provider/IDEFileProvider.kt | 19 +- .../TemplateCollectionRepositoryImpl.kt | 15 +- .../androidide/ui/compose/TooltipInterop.kt | 27 +++ .../ui/models/ExternalFileInstallUiModels.kt | 3 +- .../ui/models/PluginManagerUiState.kt | 139 ++++++++++---- .../itsaky/androidide/utils/LastValueGate.kt | 36 ++++ .../ExternalFileInstallViewModel.kt | 48 +++-- .../viewmodels/PluginManagerViewModel.kt | 177 ++++++++++-------- .../TemplateCollectionRepositoryImplTest.kt | 23 +++ .../ExternalFileInstallViewModelTest.kt | 20 +- .../androidide/idetooltips/TooltipTag.kt | 1 + 16 files changed, 427 insertions(+), 191 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt create mode 100644 app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d52f67278e..321dbdee04 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -104,9 +104,13 @@ + + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|locale|fontScale|density" /> + diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt index 7130fbc2e4..e90e319e7a 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt @@ -33,6 +33,6 @@ class ExternalFileInstallActivity : IDEActivity() { // (a rotation keeps the same instance, so this is a no-op there), and calling it // unconditionally means a process-death-recreated instance - which starts fresh and // would otherwise never see the restored intent's data - still gets processed. - viewModel.onReceived(this, uri) + viewModel.onReceived(uri) } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt index 6fa92532c2..95d05317c7 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt @@ -9,18 +9,22 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import com.itsaky.androidide.R import com.itsaky.androidide.floating.ui.FloatingTheme +import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.repositories.TemplateCollectionRepository +import com.itsaky.androidide.ui.compose.longPressTooltip import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent import com.itsaky.androidide.utils.flashError @@ -53,6 +57,7 @@ private sealed interface DialogUiState { fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { val context = LocalContext.current var dialogState by remember { mutableStateOf(DialogUiState.None) } + val isInstalling by viewModel.isInstalling.collectAsState() LaunchedEffect(viewModel) { viewModel.uiEffect.collect { effect -> @@ -60,7 +65,7 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { is ExternalFileInstallUiEffect.ForwardToPluginManager -> { context.startActivity( Intent(context, PluginManagerActivity::class.java) - .putExtra(PluginManagerActivity.EXTRA_PENDING_INSTALL_URI, effect.uri), + .putExtra(PluginManagerActivity.EXTRA_PENDING_INSTALL_FILE_PATH, effect.filePath), ) (context as? Activity)?.finish() } @@ -97,6 +102,7 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { is DialogUiState.InstallConfirm -> { InstallConfirmationDialog( state = state, + installEnabled = !isInstalling, onInstall = { viewModel.onEvent( ExternalFileInstallUiEvent.ConfirmTemplateInstall( @@ -115,6 +121,7 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { is DialogUiState.NameConflict -> { NameConflictDialog( state = state, + installEnabled = !isInstalling, onOverwrite = { viewModel.onEvent( ExternalFileInstallUiEvent.ConfirmTemplateInstall( @@ -134,6 +141,7 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { is DialogUiState.Rename -> { RenameDialog( state = state, + installEnabled = !isInstalling, suggestName = viewModel::suggestUniqueBaseName, onConfirm = { newName -> viewModel.onEvent( @@ -157,26 +165,35 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { } } +/** Comma-joined display list of a collection's template names, shared by both confirm dialogs. */ +private fun TemplateCollectionRepository.CollectionInfo.displayTemplateNames(): String = templateNames.joinToString(", ") + @Composable private fun InstallConfirmationDialog( state: DialogUiState.InstallConfirm, + installEnabled: Boolean, onInstall: () -> Unit, onDismiss: () -> Unit, ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.title_install_template_collection)) }, + title = { + Text( + stringResource(R.string.title_install_template_collection), + modifier = Modifier.longPressTooltip(TooltipTag.EXTERNAL_FILE_INSTALL), + ) + }, text = { Text( stringResource( R.string.msg_template_install_confirm, state.suggestedBaseName, - state.info.templateNames.joinToString(", "), + state.info.displayTemplateNames(), ), ) }, confirmButton = { - TextButton(onClick = onInstall) { Text(stringResource(R.string.btn_install)) } + TextButton(onClick = onInstall, enabled = installEnabled) { Text(stringResource(R.string.btn_install)) } }, dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } @@ -187,24 +204,30 @@ private fun InstallConfirmationDialog( @Composable private fun NameConflictDialog( state: DialogUiState.NameConflict, + installEnabled: Boolean, onOverwrite: () -> Unit, onRename: () -> Unit, onDismiss: () -> Unit, ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.title_template_already_installed)) }, + title = { + Text( + stringResource(R.string.title_template_already_installed), + modifier = Modifier.longPressTooltip(TooltipTag.EXTERNAL_FILE_INSTALL), + ) + }, text = { Text( stringResource( R.string.msg_template_name_conflict, state.existingName, - state.info.templateNames.joinToString(", "), + state.info.displayTemplateNames(), ), ) }, confirmButton = { - TextButton(onClick = onOverwrite) { Text(stringResource(R.string.btn_overwrite)) } + TextButton(onClick = onOverwrite, enabled = installEnabled) { Text(stringResource(R.string.btn_overwrite)) } }, dismissButton = { Row { @@ -218,6 +241,7 @@ private fun NameConflictDialog( @Composable private fun RenameDialog( state: DialogUiState.Rename, + installEnabled: Boolean, suggestName: suspend (String) -> String, onConfirm: (String) -> Unit, onDismiss: () -> Unit, @@ -239,7 +263,12 @@ private fun RenameDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.btn_rename_and_install)) }, + title = { + Text( + stringResource(R.string.btn_rename_and_install), + modifier = Modifier.longPressTooltip(TooltipTag.EXTERNAL_FILE_INSTALL), + ) + }, text = { OutlinedTextField( value = name, @@ -254,7 +283,7 @@ private fun RenameDialog( confirmButton = { TextButton( onClick = { onConfirm(name.text) }, - enabled = suggestionReady && name.text.isNotBlank(), + enabled = installEnabled && suggestionReady && name.text.isNotBlank(), ) { Text(stringResource(R.string.btn_install)) } diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index 1055b8c9f0..cde32ed617 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -13,7 +13,6 @@ import android.view.MenuItem import android.view.View import android.widget.CheckBox import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.content.IntentCompat import androidx.core.graphics.Insets import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -28,6 +27,7 @@ import com.itsaky.androidide.databinding.ActivityPluginManagerBinding import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.ui.models.PluginInstallSource import com.itsaky.androidide.ui.models.PluginManagerUiEffect import com.itsaky.androidide.ui.models.PluginManagerUiEvent import com.itsaky.androidide.utils.DURATION_INDEFINITE @@ -43,14 +43,20 @@ import com.itsaky.androidide.viewmodels.PluginManagerViewModel import kotlinx.coroutines.launch import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.koin.androidx.viewmodel.ext.android.viewModel +import java.io.File class PluginManagerActivity : EdgeToEdgeIDEActivity() { companion object { private const val TAG = "PluginManagerActivity" private const val PLUGIN_EXTENSION = ".$PLUGIN_ARCHIVE_EXTENSION" - /** A `.cgp` [Uri] forwarded from [com.itsaky.androidide.activities.ExternalFileInstallActivity]. */ - const val EXTRA_PENDING_INSTALL_URI = "pending_install_uri" + /** + * Absolute path of a `.cgp` file forwarded from + * [com.itsaky.androidide.activities.ExternalFileInstallActivity] - a plain path rather than + * a `content://` Uri, since both activities run in this same process and already trust + * filesDir paths, letting the install skip a redundant ContentResolver copy. + */ + const val EXTRA_PENDING_INSTALL_FILE_PATH = "pending_install_file_path" } @Suppress("ktlint:standard:backing-property-naming") @@ -80,7 +86,7 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { return@let } - showInstallConfirmation(it) + showInstallConfirmation(PluginInstallSource.ContentUri(it)) } } @@ -109,19 +115,17 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { setupFeedbackButton() observeViewModel() - // No savedInstanceState guard: markPendingInstallUriHandled() is the idempotency + // No savedInstanceState guard: markPendingInstallHandled() is the idempotency // check, scoped to the ViewModel instance rather than the Activity's recreation // reason - it survives rotation (skips a duplicate dialog there) but resets on // process death (a fresh ViewModel is created), so a process-death-recreated // instance still shows the dialog instead of silently dropping the forwarded // install. The intent's extra itself is preserved across both cases by the OS. - IntentCompat - .getParcelableExtra(intent, EXTRA_PENDING_INSTALL_URI, Uri::class.java) - ?.let { uri -> - if (viewModel.markPendingInstallUriHandled(uri)) { - showInstallConfirmation(uri, forceDeleteSource = true) - } + intent.getStringExtra(EXTRA_PENDING_INSTALL_FILE_PATH)?.let { filePath -> + if (viewModel.markPendingInstallHandled(filePath)) { + showInstallConfirmation(PluginInstallSource.LocalFile(File(filePath)), forceDeleteSource = true) } + } } catch (e: Exception) { // Log the error and finish the activity if something goes wrong e.printStackTrace() @@ -321,21 +325,22 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { /** * @param forceDeleteSource When true (a `.cgp` forwarded from - * [ExternalFileInstallActivity]), [uri] is our own hidden temp copy, not a file the user + * [ExternalFileInstallActivity]), [source] is our own hidden temp copy, not a file the user * picked - there's no source worth keeping, so the "delete source" checkbox is skipped * entirely and the temp file is always cleaned up. */ private fun showInstallConfirmation( - uri: Uri, + source: PluginInstallSource, forceDeleteSource: Boolean = false, ) { if (forceDeleteSource) { MaterialAlertDialogBuilder(this) .setTitle(R.string.title_install_plugin) .setPositiveButton(R.string.btn_install) { _, _ -> - viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(uri, deleteSourceAfterInstall = true)) - }.setNegativeButton(android.R.string.cancel, null) - .show() + viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(source, deleteSourceAfterInstall = true)) + }.setNegativeButton(android.R.string.cancel) { _, _ -> + viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source, deleteSourceAfterInstall = true)) + }.show() return } @@ -346,7 +351,7 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { .setTitle(R.string.title_install_plugin) .setView(dialogView) .setPositiveButton(R.string.btn_install) { _, _ -> - viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(uri, deleteCheckBox.isChecked)) + viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(source, deleteCheckBox.isChecked)) }.setNegativeButton(android.R.string.cancel, null) .show() } @@ -363,10 +368,13 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { ), ).setPositiveButton(R.string.replace) { _, _ -> viewModel.onEvent( - PluginManagerUiEvent.ConfirmOverwrite(effect.uri, effect.deleteSourceAfterInstall), + PluginManagerUiEvent.ConfirmOverwrite(effect.source, effect.deleteSourceAfterInstall), ) - }.setNegativeButton(android.R.string.cancel, null) - .show() + }.setNegativeButton(android.R.string.cancel) { _, _ -> + viewModel.onEvent( + PluginManagerUiEvent.CancelPendingInstall(effect.source, effect.deleteSourceAfterInstall), + ) + }.show() } private fun showUninstallConfirmation(plugin: PluginInfo) { diff --git a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt index 656738da19..fc3a7b0e0a 100644 --- a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt @@ -36,7 +36,6 @@ val pluginModule = pluginRepository = get(), contentResolver = androidContext().contentResolver, filesDir = androidContext().filesDir, - packageName = androidContext().packageName, ) } diff --git a/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt b/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt index 0720026267..e355b914d7 100644 --- a/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/provider/IDEFileProvider.kt @@ -17,28 +17,11 @@ package com.itsaky.androidide.provider -import android.content.Context -import android.net.Uri import androidx.core.content.FileProvider -import com.itsaky.androidide.utils.fileProviderUriFor -import java.io.File /** * AndroidIDE file provider. * * @author Akash Yadav */ -class IDEFileProvider : FileProvider() { - companion object { - /** - * Mint a `content://` [Uri] for [file] via this provider, so it can be shared with - * another component in this app without relying on a Uri permission grant to have - * carried over from wherever [file]'s bytes originally came from. - */ - @JvmStatic - fun getUriForFile( - context: Context, - file: File, - ): Uri = context.fileProviderUriFor(file) - } -} +class IDEFileProvider : FileProvider() diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt index 1a06a3ce7b..ca46846289 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -9,6 +9,7 @@ import com.itsaky.androidide.utils.Environment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION +import org.adfa.constants.TEMPLATE_CORE_ARCHIVE import java.io.File /** @@ -20,6 +21,9 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { private companion object { private const val TAG = "TemplateCollectionRepository" + /** Base filename of the bundled default templates archive - reserved, never a user collection. */ + private val RESERVED_BASE_NAME = File(TEMPLATE_CORE_ARCHIVE).nameWithoutExtension + /** Case-insensitive match by base filename - the only stable "collection identity" available. */ private fun findCollisionFile( templatesDir: File, @@ -54,7 +58,12 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { override suspend fun findExistingCollision(baseName: String): String? = withContext(Dispatchers.IO) { - Environment.TEMPLATES_DIR?.let { findCollisionFile(it, baseName) }?.nameWithoutExtension + try { + Environment.TEMPLATES_DIR?.let { findCollisionFile(it, baseName) }?.nameWithoutExtension + } catch (exception: Exception) { + Log.e(TAG, "Failed to check for an existing template collection: $baseName", exception) + null + } } override suspend fun installCollection( @@ -64,6 +73,10 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { ): Result = withContext(Dispatchers.IO) { runCatching { + if (targetBaseName.equals(RESERVED_BASE_NAME, ignoreCase = true)) { + throw IllegalStateException("\"$targetBaseName\" is a reserved name and cannot be used") + } + val templatesDir = Environment.TEMPLATES_DIR ?: throw IllegalStateException("Templates system not available") diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt new file mode 100644 index 0000000000..e66e9051d8 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt @@ -0,0 +1,27 @@ +package com.itsaky.androidide.ui.compose + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import com.itsaky.androidide.idetooltips.TooltipManager + +/** + * Wires the existing long-press help system (`idetooltips`) into a composable. Compose has no + * native tooltip entry point yet (the bridge is tracked as ADFA-4381) - this reuses + * [TooltipManager] via interop instead of a one-off popup, anchored to the Compose hierarchy's + * root [android.view.View] since a `content://`/dialog composable has no Android `View` of its + * own to anchor a popup on. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun Modifier.longPressTooltip(tag: String): Modifier { + val context = LocalContext.current + val anchorView = LocalView.current + return combinedClickable( + onClick = {}, + onLongClick = { TooltipManager.showIdeCategoryTooltip(context, anchorView, tag) }, + ) +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt index 6318da1bd6..611e0128d4 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt @@ -1,6 +1,5 @@ package com.itsaky.androidide.ui.models -import android.net.Uri import androidx.annotation.StringRes import com.itsaky.androidide.repositories.TemplateCollectionRepository import java.io.File @@ -19,7 +18,7 @@ sealed class ExternalFileInstallUiEvent { sealed class ExternalFileInstallUiEffect { data class ForwardToPluginManager( - val uri: Uri, + val filePath: String, ) : ExternalFileInstallUiEffect() data class ShowTemplateInstallConfirmation( diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt index 151d631f4c..54e931b91d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt @@ -4,51 +4,120 @@ import android.net.Uri import androidx.annotation.StringRes import com.itsaky.androidide.plugins.PluginInfo import com.itsaky.androidide.plugins.PluginMetadata +import java.io.File data class PluginManagerUiState( - val isLoading: Boolean = false, - val plugins: List = emptyList(), - val isPluginManagerAvailable: Boolean = false, - val isInstalling: Boolean = false + val isLoading: Boolean = false, + val plugins: List = emptyList(), + val isPluginManagerAvailable: Boolean = false, + val isInstalling: Boolean = false, ) { - val isEmpty: Boolean - get() = plugins.isEmpty() && !isLoading + val isEmpty: Boolean + get() = plugins.isEmpty() && !isLoading - val showEmptyState: Boolean - get() = isEmpty && isPluginManagerAvailable + val showEmptyState: Boolean + get() = isEmpty && isPluginManagerAvailable +} + +/** + * Where a plugin archive to install comes from - either a `content://` [Uri] the user picked via + * SAF (any provider, including third-party ones), or a plain [File] this process already owns + * (the forwarded-`.cgp` case from [com.itsaky.androidide.activities.ExternalFileInstallActivity], + * which needs no [android.content.ContentResolver] round-trip since it's already a private file). + */ +sealed class PluginInstallSource { + data class ContentUri( + val uri: Uri, + ) : PluginInstallSource() + + data class LocalFile( + val file: File, + ) : PluginInstallSource() } sealed class PluginManagerUiEvent { - object LoadPlugins : PluginManagerUiEvent() - data class EnablePlugin(val pluginId: String) : PluginManagerUiEvent() - data class DisablePlugin(val pluginId: String) : PluginManagerUiEvent() - data class UninstallPlugin(val pluginId: String) : PluginManagerUiEvent() - data class InstallPlugin(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent() - data class ConfirmOverwrite(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent() - object OpenFilePicker : PluginManagerUiEvent() - data class ShowPluginDetails(val plugin: PluginInfo) : PluginManagerUiEvent() + object LoadPlugins : PluginManagerUiEvent() + + data class EnablePlugin( + val pluginId: String, + ) : PluginManagerUiEvent() + + data class DisablePlugin( + val pluginId: String, + ) : PluginManagerUiEvent() + + data class UninstallPlugin( + val pluginId: String, + ) : PluginManagerUiEvent() + + data class InstallPlugin( + val source: PluginInstallSource, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEvent() + + data class ConfirmOverwrite( + val source: PluginInstallSource, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEvent() + + data class CancelPendingInstall( + val source: PluginInstallSource, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEvent() + + object OpenFilePicker : PluginManagerUiEvent() + + data class ShowPluginDetails( + val plugin: PluginInfo, + ) : PluginManagerUiEvent() } sealed class PluginManagerUiEffect { - data class ShowError(@StringRes val messageResId: Int, val formatArgs: List = emptyList()) : PluginManagerUiEffect() - data class ShowSuccess(@StringRes val messageResId: Int) : PluginManagerUiEffect() - data class ShowPluginDetails(val plugin: PluginInfo) : PluginManagerUiEffect() - object OpenFilePicker : PluginManagerUiEffect() - data class ShowUninstallConfirmation(val plugin: PluginInfo) : PluginManagerUiEffect() - object ShowRestartPrompt : PluginManagerUiEffect() - data class ShowOverwriteConfirmation( - val existing: PluginInfo, - val incomingMetadata: PluginMetadata, - val uri: Uri, - val deleteSourceAfterInstall: Boolean - ) : PluginManagerUiEffect() + data class ShowError( + @StringRes val messageResId: Int, + val formatArgs: List = emptyList(), + ) : PluginManagerUiEffect() + + data class ShowSuccess( + @StringRes val messageResId: Int, + ) : PluginManagerUiEffect() + + data class ShowPluginDetails( + val plugin: PluginInfo, + ) : PluginManagerUiEffect() + + object OpenFilePicker : PluginManagerUiEffect() + + data class ShowUninstallConfirmation( + val plugin: PluginInfo, + ) : PluginManagerUiEffect() + + object ShowRestartPrompt : PluginManagerUiEffect() + + data class ShowOverwriteConfirmation( + val existing: PluginInfo, + val incomingMetadata: PluginMetadata, + val source: PluginInstallSource, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEffect() } sealed class PluginOperation { - object None : PluginOperation() - object Loading : PluginOperation() - object Installing : PluginOperation() - data class Enabling(val pluginId: String) : PluginOperation() - data class Disabling(val pluginId: String) : PluginOperation() - data class Uninstalling(val pluginId: String) : PluginOperation() -} \ No newline at end of file + object None : PluginOperation() + + object Loading : PluginOperation() + + object Installing : PluginOperation() + + data class Enabling( + val pluginId: String, + ) : PluginOperation() + + data class Disabling( + val pluginId: String, + ) : PluginOperation() + + data class Uninstalling( + val pluginId: String, + ) : PluginOperation() +} diff --git a/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt b/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt new file mode 100644 index 0000000000..39dc45f2a3 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt @@ -0,0 +1,36 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.utils + +/** + * Tracks the last value handed to [consume], so a caller can tell "already handled" from "new" + * without an Activity `savedInstanceState` check. Meant to live as a field on a `ViewModel`: it + * survives a configuration change (same instance, so a repeat [consume] of the same value is a + * no-op), but resets after process death (a fresh instance is created), so a process-death + * recreation still processes a restored pending value instead of silently dropping it. + */ +class LastValueGate { + private var lastHandled: T? = null + + /** Returns true the first time [value] is passed, or if it differs from the last one seen. */ + fun consume(value: T): Boolean { + if (lastHandled == value) return false + lastHandled = value + return true + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index 1fb053146b..9095ab2d23 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -1,24 +1,26 @@ package com.itsaky.androidide.viewmodels import android.content.ContentResolver -import android.content.Context import android.net.Uri import android.util.Log import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.itsaky.androidide.provider.IDEFileProvider import com.itsaky.androidide.repositories.PluginRepository import com.itsaky.androidide.repositories.TemplateCollectionRepository import com.itsaky.androidide.resources.R import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent +import com.itsaky.androidide.utils.LastValueGate import com.itsaky.androidide.utils.UriFileImporter import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -47,6 +49,12 @@ class ExternalFileInstallViewModel( // instead of failing on the very first check. private const val SETUP_WAIT_ATTEMPTS = 10 private const val SETUP_WAIT_INTERVAL_MS = 300L + + // Gives Flashbar's async layout-triggered entrance animation (see FlashbarContainerView's + // afterMeasured{}) a chance to actually start before Finish tears the window down - + // without this, ShowError/ShowSuccess sent immediately before Finish can be dismissed + // before ever rendering. + private const val FLASH_MESSAGE_DELAY_MS = 300L } // Buffered (not rendezvous): onReceived() runs via Dispatchers.Main.immediate right after @@ -56,19 +64,18 @@ class ExternalFileInstallViewModel( private val _uiEffect = Channel(capacity = Channel.BUFFERED) val uiEffect = _uiEffect.receiveAsFlow() - // onReceived() must run exactly once per ViewModel instance: this instance survives a - // rotation (so a duplicate call there is a no-op, not a re-processed intent), but is - // recreated fresh by Koin after process death (so the fresh instance still processes the - // restored intent instead of the call being skipped entirely). - private var received = false + // onReceived() must run at most once per distinct uri per ViewModel instance: this instance + // survives a rotation (so a duplicate call there for the same uri is a no-op, not a + // re-processed intent), but is recreated fresh by Koin after process death (so the fresh + // instance still processes the restored intent instead of the call being skipped entirely). + private val receivedUriGate = LastValueGate() + + private val _isInstalling = MutableStateFlow(false) + val isInstalling: StateFlow = _isInstalling.asStateFlow() /** Call once, from `Activity.onCreate()`, with the VIEW intent's data [Uri]. */ - fun onReceived( - context: Context, - uri: Uri, - ) { - if (received) return - received = true + fun onReceived(uri: Uri) { + if (!receivedUriGate.consume(uri)) return viewModelScope.launch { val displayName = withContext(Dispatchers.IO) { UriFileImporter.getDisplayName(contentResolver, uri) } @@ -122,8 +129,10 @@ class ExternalFileInstallViewModel( val baseName = sanitizeBaseName(displayName.substringBeforeLast('.', "templates")) if (extension == PLUGIN_ARCHIVE_EXTENSION) { - val fileProviderUri = IDEFileProvider.getUriForFile(context, tempFile) - _uiEffect.trySend(ExternalFileInstallUiEffect.ForwardToPluginManager(fileProviderUri)) + // Forwarded as a plain path, not a content:// Uri: both activities run in this + // same process and already trust filesDir paths, so PluginManagerViewModel can + // install straight from this file instead of copying it a second time. + _uiEffect.trySend(ExternalFileInstallUiEffect.ForwardToPluginManager(tempFile.absolutePath)) } else { dispatchTemplateInstall(tempFile, baseName) } @@ -182,11 +191,18 @@ class ExternalFileInstallViewModel( targetBaseName: String, overwrite: Boolean, ) { + // Guards against a double-tap on Install/Overwrite/Rename firing this twice concurrently - + // the second call's renameTo()/copyTo() would otherwise race the first's on the same + // tempFile and surface a spurious failure toast. + if (_isInstalling.value) return + _isInstalling.value = true + viewModelScope.launch { templateCollectionRepository .installCollection(tempFile, targetBaseName, overwrite) .onSuccess { _uiEffect.trySend(ExternalFileInstallUiEffect.ShowSuccess(R.string.msg_template_installed)) + delay(FLASH_MESSAGE_DELAY_MS) _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) }.onFailure { exception -> // Deliberately don't delete tempFile or Finish here: the dialog the user was @@ -195,6 +211,7 @@ class ExternalFileInstallViewModel( Log.e(TAG, "Failed to install template collection", exception) _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(R.string.msg_template_install_failed)) } + _isInstalling.value = false } } @@ -215,6 +232,7 @@ class ExternalFileInstallViewModel( @StringRes messageResId: Int, ) { _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(messageResId)) + delay(FLASH_MESSAGE_DELAY_MS) _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index da445b622b..54c18c73be 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -9,13 +9,14 @@ import androidx.lifecycle.viewModelScope import com.itsaky.androidide.plugins.PluginInfo import com.itsaky.androidide.repositories.PluginRepository import com.itsaky.androidide.resources.R +import com.itsaky.androidide.ui.models.PluginInstallSource import com.itsaky.androidide.ui.models.PluginManagerUiEffect import com.itsaky.androidide.ui.models.PluginManagerUiEvent import com.itsaky.androidide.ui.models.PluginManagerUiState import com.itsaky.androidide.ui.models.PluginOperation import com.itsaky.androidide.utils.EditorDecorationBridge +import com.itsaky.androidide.utils.LastValueGate import com.itsaky.androidide.utils.UriFileImporter -import com.itsaky.androidide.utils.fileProviderAuthorityFor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -25,6 +26,7 @@ import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import java.io.File import java.util.UUID @@ -36,32 +38,22 @@ class PluginManagerViewModel( private val pluginRepository: PluginRepository, private val contentResolver: ContentResolver, private val filesDir: File, - packageName: String, ) : ViewModel() { private companion object { private const val TAG = "PluginManagerViewModel" } - private val fileProviderAuthority = fileProviderAuthorityFor(packageName) - - // Tracks the last forwarded-install Uri (from ExternalFileInstallActivity) this instance has - // already shown a dialog for. Survives rotation (same ViewModel instance, via the - // ViewModelStore) so the dialog isn't re-popped on every rotation, but resets on process + // Tracks the last forwarded-install file path (from ExternalFileInstallActivity) this + // instance has already shown a dialog for. Survives rotation (same ViewModel instance, via + // the ViewModelStore) so the dialog isn't re-popped on every rotation, but resets on process // death (a fresh instance is created), so a process-death-recreated PluginManagerActivity // still shows the dialog instead of silently dropping the forwarded install. - private var handledPendingInstallUri: Uri? = null + private val pendingInstallGate = LastValueGate() - /** - * Returns true the first time [uri] is seen by this ViewModel instance - see - * [handledPendingInstallUri] for why this, rather than an Activity `savedInstanceState` + /** See [pendingInstallGate] for why this, rather than an Activity `savedInstanceState` * check, is what correctly distinguishes "already shown after a rotation" from "never shown - * because the process died". - */ - fun markPendingInstallUriHandled(uri: Uri): Boolean { - if (handledPendingInstallUri == uri) return false - handledPendingInstallUri = uri - return true - } + * because the process died". */ + fun markPendingInstallHandled(filePath: String): Boolean = pendingInstallGate.consume(filePath) // Mutable state for internal updates private val _uiState = @@ -109,19 +101,25 @@ class PluginManagerViewModel( is PluginManagerUiEvent.InstallPlugin -> { installPlugin( - event.uri, + event.source, event.deleteSourceAfterInstall, ) } is PluginManagerUiEvent.ConfirmOverwrite -> { installPlugin( - event.uri, + event.source, event.deleteSourceAfterInstall, checkConflict = false, ) } + is PluginManagerUiEvent.CancelPendingInstall -> { + if (event.deleteSourceAfterInstall) { + viewModelScope.launch { deleteInstallSource(event.source) } + } + } + is PluginManagerUiEvent.OpenFilePicker -> { openFilePicker() } @@ -286,7 +284,7 @@ class PluginManagerViewModel( } private fun installPlugin( - uri: Uri, + source: PluginInstallSource, deleteSourceAfterInstall: Boolean, checkConflict: Boolean = true, ) { @@ -294,38 +292,46 @@ class PluginManagerViewModel( _currentOperation.value = PluginOperation.Installing _uiState.update { it.copy(isInstalling = true) } - var tempFile: File? = null + // Only a copy this function made itself (ContentUri case) is unconditionally safe to + // delete below - a forwarded LocalFile is the caller's file, and its lifecycle is + // governed by deleteSourceAfterInstall/deleteInstallSource instead. + var ownedTempFile: File? = null + var pluginFile: File? = null try { - tempFile = - withContext(Dispatchers.IO) { - val fileName = UriFileImporter.getDisplayName(contentResolver, uri) - val extension = - if (fileName?.endsWith( - ".cgp", - ignoreCase = true, - ) == true - ) { - ".cgp" - } else { - ".apk" - } - val tempFileName = "temp_plugin_${UUID.randomUUID()}$extension" - val tempDir = File(filesDir, "temp").apply { mkdirs() } - val tempFile = File(tempDir, tempFileName) - - UriFileImporter.copyUriToFile(contentResolver, uri, tempFile) { - Exception("Cannot open file") + pluginFile = + when (source) { + is PluginInstallSource.LocalFile -> { + source.file + } + + is PluginInstallSource.ContentUri -> { + withContext(Dispatchers.IO) { + val fileName = UriFileImporter.getDisplayName(contentResolver, source.uri) + val extension = + if (fileName?.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true) == true) { + ".$PLUGIN_ARCHIVE_EXTENSION" + } else { + ".apk" + } + val tempFileName = "temp_plugin_${UUID.randomUUID()}$extension" + val tempDir = File(filesDir, "temp").apply { mkdirs() } + val tempFile = File(tempDir, tempFileName) + + UriFileImporter.copyUriToFile(contentResolver, source.uri, tempFile) { + Exception("Cannot open file") + } + tempFile + }.also { ownedTempFile = it } } - tempFile } - if (checkConflict && resolveInstallConflict(tempFile, uri, deleteSourceAfterInstall)) { + if (checkConflict && resolveInstallConflict(pluginFile, source, deleteSourceAfterInstall)) { return@launch } pluginRepository - .installPluginFromFile(tempFile) + .installPluginFromFile(pluginFile) .onSuccess { Log.d(TAG, "Plugin installed successfully") _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) @@ -333,7 +339,7 @@ class PluginManagerViewModel( _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) if (deleteSourceAfterInstall) { - deleteSourceDocument(uri) + deleteInstallSource(source) } }.onFailure { exception -> Log.e(TAG, "Failed to install plugin", exception) @@ -343,6 +349,9 @@ class PluginManagerViewModel( listOf(exception.message ?: ""), ), ) + if (deleteSourceAfterInstall) { + deleteInstallSource(source) + } } } catch (exception: Exception) { Log.e(TAG, "Error installing plugin from URI", exception) @@ -352,8 +361,11 @@ class PluginManagerViewModel( listOf(exception.message ?: ""), ), ) + if (deleteSourceAfterInstall) { + deleteInstallSource(source) + } } finally { - tempFile?.let { file -> + ownedTempFile?.let { file -> withContext(Dispatchers.IO) { if (file.exists()) { file.delete() @@ -367,14 +379,15 @@ class PluginManagerViewModel( } private suspend fun resolveInstallConflict( - tempFile: File, - uri: Uri, + pluginFile: File, + source: PluginInstallSource, deleteSourceAfterInstall: Boolean, ): Boolean { - val incoming = pluginRepository.getPluginMetadataFromFile(tempFile).getOrNull() + val incoming = pluginRepository.getPluginMetadataFromFile(pluginFile).getOrNull() if (incoming == null) { - Log.w(TAG, "Failed to read plugin metadata from ${tempFile.name}; aborting install") + Log.w(TAG, "Failed to read plugin metadata from ${pluginFile.name}; aborting install") _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file)) + if (deleteSourceAfterInstall) deleteInstallSource(source) return true } @@ -384,44 +397,54 @@ class PluginManagerViewModel( val signaturesMatch = pluginRepository - .haveMatchingSignatures(tempFile, existing.metadata.id) + .haveMatchingSignatures(pluginFile, existing.metadata.id) .getOrDefault(false) - val effect = - if (!signaturesMatch) { + if (!signaturesMatch) { + _uiEffect.trySend( PluginManagerUiEffect.ShowError( R.string.msg_plugin_signature_mismatch, listOf(existing.metadata.name), - ) - } else { - PluginManagerUiEffect.ShowOverwriteConfirmation( - existing = existing, - incomingMetadata = incoming, - uri = uri, - deleteSourceAfterInstall = deleteSourceAfterInstall, - ) - } - _uiEffect.trySend(effect) + ), + ) + if (deleteSourceAfterInstall) deleteInstallSource(source) + return true + } + + // Deliberately don't delete the source yet: the user still needs to choose Replace or + // Cancel. ConfirmOverwrite re-runs installPlugin() to consume it on Replace; + // CancelPendingInstall cleans it up if they back out instead. + _uiEffect.trySend( + PluginManagerUiEffect.ShowOverwriteConfirmation( + existing = existing, + incomingMetadata = incoming, + source = source, + deleteSourceAfterInstall = deleteSourceAfterInstall, + ), + ) return true } + private suspend fun deleteInstallSource(source: PluginInstallSource) { + when (source) { + is PluginInstallSource.LocalFile -> { + withContext(Dispatchers.IO) { + if (source.file.exists() && !source.file.delete()) { + Log.w(TAG, "Failed to delete forwarded install file: ${source.file.absolutePath}") + } + } + } + + is PluginInstallSource.ContentUri -> { + deleteSourceDocument(source.uri) + } + } + } + private suspend fun deleteSourceDocument(uri: Uri) { withContext(Dispatchers.IO) { try { - // DocumentsContract.deleteDocument() only works against a real SAF - // DocumentsProvider: it calls the provider's special METHOD_DELETE_DOCUMENT via - // ContentProvider.call(), and returns true unconditionally unless an exception is - // thrown. Our own IDEFileProvider (used for a .cgp forwarded from - // ExternalFileInstallActivity) doesn't implement that call - so deleteDocument() - // against one of its Uris silently "succeeds" without deleting anything. FileProvider - // does properly implement plain delete(), so use that for our own authority instead. - val deleted = - if (uri.authority == fileProviderAuthority) { - contentResolver.delete(uri, null, null) > 0 - } else { - DocumentsContract.deleteDocument(contentResolver, uri) - } - if (!deleted) { + if (!DocumentsContract.deleteDocument(contentResolver, uri)) { _uiEffect.trySend( PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), ) diff --git a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt index aabbe62307..8498c70723 100644 --- a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt +++ b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt @@ -133,4 +133,27 @@ class TemplateCollectionRepositoryImplTest { assertThat(result.isSuccess).isTrue() assertThat(destination.readText()).isNotEqualTo("stale content") } + + @Test + fun `installCollection refuses to replace the reserved bundled core archive, even with overwrite`() = + runTest { + val bundledCore = File(templatesDir, "core.cgt") + bundledCore.writeText("bundled default templates") + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "core", overwrite = true) + + assertThat(result.isFailure).isTrue() + assertThat(bundledCore.readText()).isEqualTo("bundled default templates") + } + + @Test + fun `installCollection refuses a reserved name case-insensitively`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "CORE", overwrite = true) + + assertThat(result.isFailure).isTrue() + } } diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt index 74b70d71a3..7c996b7131 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt @@ -73,7 +73,7 @@ class ExternalFileInstallViewModelTest { @Test fun `unsupported extension shows error and finishes`() = runTest { - viewModel.onReceived(context, sourceUriFor("notes.txt")) + viewModel.onReceived(sourceUriFor("notes.txt")) val first = viewModel.uiEffect.first() assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) @@ -84,7 +84,7 @@ class ExternalFileInstallViewModelTest { runTest { stubPluginManagerAvailable(false) - viewModel.onReceived(context, sourceUriFor("my-plugin.cgp")) + viewModel.onReceived(sourceUriFor("my-plugin.cgp")) val first = viewModel.uiEffect.first() assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) @@ -95,7 +95,7 @@ class ExternalFileInstallViewModelTest { runTest { stubTemplatesFeatureAvailable(false) - viewModel.onReceived(context, sourceUriFor("my-templates.cgt")) + viewModel.onReceived(sourceUriFor("my-templates.cgt")) val first = viewModel.uiEffect.first() assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) @@ -106,7 +106,7 @@ class ExternalFileInstallViewModelTest { runTest { stubPluginManagerAvailable(true) - viewModel.onReceived(context, sourceUriFor("my-plugin.cgp")) + viewModel.onReceived(sourceUriFor("my-plugin.cgp")) val first = viewModel.uiEffect.first() assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java) @@ -120,7 +120,7 @@ class ExternalFileInstallViewModelTest { coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null - viewModel.onReceived(context, sourceUriFor("my-templates.cgt")) + viewModel.onReceived(sourceUriFor("my-templates.cgt")) val first = viewModel.uiEffect.first() assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation::class.java) @@ -137,7 +137,7 @@ class ExternalFileInstallViewModelTest { coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) coEvery { templateCollectionRepository.findExistingCollision(any()) } returns "my-templates" - viewModel.onReceived(context, sourceUriFor("my-templates.cgt")) + viewModel.onReceived(sourceUriFor("my-templates.cgt")) val first = viewModel.uiEffect.first() assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateNameConflict::class.java) @@ -151,7 +151,7 @@ class ExternalFileInstallViewModelTest { coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.failure(IllegalArgumentException("no templates")) - viewModel.onReceived(context, sourceUriFor("broken.cgt")) + viewModel.onReceived(sourceUriFor("broken.cgt")) val first = viewModel.uiEffect.first() assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) @@ -163,8 +163,8 @@ class ExternalFileInstallViewModelTest { stubPluginManagerAvailable(true) val uri = sourceUriFor("my-plugin.cgp") - viewModel.onReceived(context, uri) - viewModel.onReceived(context, uri) + viewModel.onReceived(uri) + viewModel.onReceived(uri) val first = viewModel.uiEffect.first() assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java) @@ -176,7 +176,7 @@ class ExternalFileInstallViewModelTest { runTest { every { pluginRepository.isPluginManagerAvailable() } returnsMany listOf(false, false, true) - viewModel.onReceived(context, sourceUriFor("my-plugin.cgp")) + viewModel.onReceived(sourceUriFor("my-plugin.cgp")) val first = viewModel.uiEffect.first() assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java) diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index 02a0571d1f..48cd034416 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -50,6 +50,7 @@ object TooltipTag { const val PREFS_EDITOR_XML = "prefs.editor.xml" const val PREFS_DEVELOPER = "prefs.developer" const val PLUGIN_MANAGER = "plugin.manager" + const val EXTERNAL_FILE_INSTALL = "external.file.install" const val TEMPLATE_TABBED_ACTIVITY = "template.tabbed.activity" const val TEMPLATE_LEGACY_PROJECT = "template.legacy.project" const val TEMPLATE_EMPTY_ACTIVITY = "template.empty.activity" From ce36e12189934e74ffa1e00940c235ed59d2a614 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 16 Aug 2026 11:00:28 -0700 Subject: [PATCH 08/23] ADFA-4934: Address architecture-review findings - Use collectAsStateWithLifecycle() instead of collectAsState() per ADR 0009's explicit guidance, adding the lifecycle-runtime-compose dependency it calls for (the app had none yet). - Update ARCHITECTURE.md's PluginManagerUiEvent.InstallPlugin example to match the PluginInstallSource change from the prior commit. Co-Authored-By: Claude Sonnet 5 --- ARCHITECTURE.md | 2 +- app/build.gradle.kts | 1 + .../itsaky/androidide/activities/ExternalFileInstallScreen.kt | 4 ++-- gradle/libs.versions.toml | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3ecaccc691..dfcf41e4f1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -147,7 +147,7 @@ data class PluginManagerUiState( sealed class PluginManagerUiEvent { object LoadPlugins : PluginManagerUiEvent() data class EnablePlugin(val pluginId: String) : PluginManagerUiEvent() - data class InstallPlugin(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent() + data class InstallPlugin(val source: PluginInstallSource, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent() // ... } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8fed4c3283..21c52a5fc2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -253,6 +253,7 @@ dependencies { implementation(libs.compose.foundation) implementation(libs.compose.material3) implementation(libs.compose.activity) + implementation(libs.compose.lifecycle.runtime) implementation(libs.compose.ui.tooling.preview) debugImplementation(libs.compose.ui.tooling) diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt index 95d05317c7..dc7f0713d4 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -20,6 +19,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.itsaky.androidide.R import com.itsaky.androidide.floating.ui.FloatingTheme import com.itsaky.androidide.idetooltips.TooltipTag @@ -57,7 +57,7 @@ private sealed interface DialogUiState { fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { val context = LocalContext.current var dialogState by remember { mutableStateOf(DialogUiState.None) } - val isInstalling by viewModel.isInstalling.collectAsState() + val isInstalling by viewModel.isInstalling.collectAsStateWithLifecycle() LaunchedEffect(viewModel) { viewModel.uiEffect.collect { effect -> diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c4e15649b..5648a02daf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -137,6 +137,7 @@ compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-material3 = { module = "androidx.compose.material3:material3" } compose-activity = { module = "androidx.activity:activity-compose", version = "1.8.2" } +compose-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" } # Firebase firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" } From f33b81179fd74113644dc5e2bb984ca98d4111d6 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 16 Aug 2026 11:17:11 -0700 Subject: [PATCH 09/23] ADFA-4934: Address CodeRabbit review findings - findCollisionFile: match the .cgt extension case-insensitively, consistent with the already-case-insensitive base-name match (the manifest accepts uppercase .CGT); added a regression test. - findExistingCollision: rethrow CancellationException instead of swallowing it as a null result, preserving coroutine cancellation. - longPressTooltip: add an onLongClickLabel (new cd_show_help string) so screen readers can discover the long-press help action. - LastValueGate: document that consume() is not thread-safe. Co-Authored-By: Claude Sonnet 5 --- .../repositories/TemplateCollectionRepositoryImpl.kt | 5 ++++- .../com/itsaky/androidide/ui/compose/TooltipInterop.kt | 8 +++++++- .../java/com/itsaky/androidide/utils/LastValueGate.kt | 3 +++ .../TemplateCollectionRepositoryImplTest.kt | 10 ++++++++++ resources/src/main/res/values/strings.xml | 1 + 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt index ca46846289..e2a65e205f 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -6,6 +6,7 @@ import com.itsaky.androidide.templates.TemplateRecipe import com.itsaky.androidide.templates.impl.TemplateWarning import com.itsaky.androidide.templates.impl.zip.ZipTemplateReader import com.itsaky.androidide.utils.Environment +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION @@ -30,7 +31,7 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { baseName: String, ): File? = templatesDir - .listFiles { file -> file.extension == TEMPLATE_ARCHIVE_EXTENSION } + .listFiles { file -> file.extension.equals(TEMPLATE_ARCHIVE_EXTENSION, ignoreCase = true) } ?.firstOrNull { it.nameWithoutExtension.equals(baseName, ignoreCase = true) } } @@ -60,6 +61,8 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { withContext(Dispatchers.IO) { try { Environment.TEMPLATES_DIR?.let { findCollisionFile(it, baseName) }?.nameWithoutExtension + } catch (e: CancellationException) { + throw e } catch (exception: Exception) { Log.e(TAG, "Failed to check for an existing template collection: $baseName", exception) null diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt index e66e9051d8..a6667bb866 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt @@ -6,6 +6,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import com.itsaky.androidide.R import com.itsaky.androidide.idetooltips.TooltipManager /** @@ -17,11 +19,15 @@ import com.itsaky.androidide.idetooltips.TooltipManager */ @OptIn(ExperimentalFoundationApi::class) @Composable -fun Modifier.longPressTooltip(tag: String): Modifier { +fun Modifier.longPressTooltip( + tag: String, + onLongClickLabel: String = stringResource(R.string.cd_show_help), +): Modifier { val context = LocalContext.current val anchorView = LocalView.current return combinedClickable( onClick = {}, + onLongClickLabel = onLongClickLabel, onLongClick = { TooltipManager.showIdeCategoryTooltip(context, anchorView, tag) }, ) } diff --git a/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt b/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt index 39dc45f2a3..6c894807a6 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt @@ -23,6 +23,9 @@ package com.itsaky.androidide.utils * survives a configuration change (same instance, so a repeat [consume] of the same value is a * no-op), but resets after process death (a fresh instance is created), so a process-death * recreation still processes a restored pending value instead of silently dropping it. + * + * Not thread-safe: [lastHandled] is unsynchronized, so call [consume] from a single thread only + * (e.g. always from the main thread, as every current call site does). */ class LastValueGate { private var lastHandled: T? = null diff --git a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt index 8498c70723..6775d2764b 100644 --- a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt +++ b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt @@ -90,6 +90,16 @@ class TemplateCollectionRepositoryImplTest { assertThat(match).isEqualTo("MyTemplates") } + @Test + fun `findExistingCollision matches an uppercase CGT extension`() = + runTest { + File(templatesDir, "MyTemplates.CGT").writeText("placeholder") + + val match = repository.findExistingCollision("mytemplates") + + assertThat(match).isEqualTo("MyTemplates") + } + @Test fun `findExistingCollision returns null when there is no match`() = runTest { diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 771598e7a5..c1fc5c09c0 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1016,6 +1016,7 @@ Error Warning Information + Show help Quick run From f896c47c992d76dd6d2bb06aefd7dc4cb63b74ef Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 16 Aug 2026 22:11:43 -0700 Subject: [PATCH 10/23] ADFA-4934: Address remaining CodeRabbit findings on PR #1682 - Reject path-traversal in TemplateCollectionRepositoryImpl.installCollection (targetBaseName can no longer contain a separator, and the resolved path is verified to stay directly under templatesDir). - Stage the incoming archive fully under templatesDir before deleting an existing collection, so a failed write can no longer destroy it. - Rethrow CancellationException before the broad catch in PluginManagerViewModel.installPlugin, and run its temp-file cleanup under NonCancellable, matching the pattern already used elsewhere. - Switch the two new files' logging (ExternalFileInstallViewModel, TemplateCollectionRepositoryImpl) from android.util.Log to SLF4J, per REVIEW.md's logging convention. - Add unit tests for FileProviderUtils, the new path-traversal guard, and the preserve-existing-on-failed-write behavior. - Use TemporaryFolder instead of the real Robolectric filesDir for ExternalFileInstallViewModelTest's output files. Co-Authored-By: Claude Sonnet 5 --- .../TemplateCollectionRepositoryImpl.kt | 59 ++++++++++++++----- .../ExternalFileInstallViewModel.kt | 10 ++-- .../viewmodels/PluginManagerViewModel.kt | 6 +- .../TemplateCollectionRepositoryImplTest.kt | 37 ++++++++++++ .../ExternalFileInstallViewModelTest.kt | 10 +--- .../androidide/utils/FileProviderUtilsTest.kt | 30 ++++++++++ 6 files changed, 123 insertions(+), 29 deletions(-) create mode 100644 common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt index e2a65e205f..d767ad4b01 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -1,6 +1,5 @@ package com.itsaky.androidide.repositories -import android.util.Log import com.itsaky.androidide.templates.ITemplateProvider import com.itsaky.androidide.templates.TemplateRecipe import com.itsaky.androidide.templates.impl.TemplateWarning @@ -11,6 +10,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import org.adfa.constants.TEMPLATE_CORE_ARCHIVE +import org.slf4j.LoggerFactory import java.io.File /** @@ -20,7 +20,7 @@ import java.io.File */ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { private companion object { - private const val TAG = "TemplateCollectionRepository" + private val log = LoggerFactory.getLogger(TemplateCollectionRepositoryImpl::class.java) /** Base filename of the bundled default templates archive - reserved, never a user collection. */ private val RESERVED_BASE_NAME = File(TEMPLATE_CORE_ARCHIVE).nameWithoutExtension @@ -45,7 +45,7 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { } if (templates.isEmpty()) { - warnings.forEach { Log.w(TAG, "Template read warning: resId=${it.resId}, args=${it.args}") } + warnings.forEach { log.warn("Template read warning: resId={}, args={}", it.resId, it.args) } throw IllegalArgumentException("No valid templates found in archive: ${candidateFile.name}") } @@ -53,7 +53,7 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { templateNames = templates.map { it.templateNameStr }, ) }.onFailure { exception -> - Log.e(TAG, "Failed to inspect template collection: ${candidateFile.absolutePath}", exception) + log.error("Failed to inspect template collection: {}", candidateFile.name, exception) } } @@ -64,7 +64,7 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { } catch (e: CancellationException) { throw e } catch (exception: Exception) { - Log.e(TAG, "Failed to check for an existing template collection: $baseName", exception) + log.error("Failed to check for an existing template collection: {}", baseName, exception) null } } @@ -80,6 +80,18 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { throw IllegalStateException("\"$targetBaseName\" is a reserved name and cannot be used") } + // targetBaseName ends up as a single path segment below; reject anything that + // could make it span multiple segments (or escape templatesDir entirely) before + // it ever reaches a File constructor. + if (targetBaseName.isBlank() || + targetBaseName.contains('/') || + targetBaseName.contains('\\') || + targetBaseName == "." || + targetBaseName == ".." + ) { + throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"") + } + val templatesDir = Environment.TEMPLATES_DIR ?: throw IllegalStateException("Templates system not available") @@ -97,25 +109,44 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { // Overwrite the existing case-variant file in place (preserving its casing) // rather than create a second, case-differing duplicate. val destFile = existingMatch ?: File(templatesDir, "$targetBaseName.$TEMPLATE_ARCHIVE_EXTENSION") + + // Belt-and-braces against the character check above: confirm the resolved path + // still lands directly inside templatesDir once symlinks/".." are resolved. + if (destFile.canonicalFile.parentFile != templatesDir.canonicalFile) { + throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"") + } + + // Stage the incoming archive fully under templatesDir before touching destFile, + // so a failure while writing the new content never destroys the existing + // collection - only once the new file is confirmed on disk do we delete the old + // one and swap the staged file into place. + val stagingFile = File(templatesDir, "${destFile.name}.tmp") + if (!candidateFile.renameTo(stagingFile)) { + candidateFile.copyTo(stagingFile, overwrite = true) + if (!candidateFile.delete()) { + log.warn("Installed but failed to delete source temp file: {}", candidateFile.name) + } + } + if (destFile.exists() && !destFile.delete()) { + stagingFile.delete() throw IllegalStateException("Failed to replace existing file: ${destFile.name}") } - // Try an atomic move first; File.renameTo() is unreliable on Android even within - // the same app's private storage (confirmed on a physical device: it silently - // fails here despite temp/ and templates/ both being under filesDir), so fall - // back to copy+delete rather than trust it unconditionally. - if (!candidateFile.renameTo(destFile)) { - candidateFile.copyTo(destFile, overwrite = true) - if (!candidateFile.delete()) { - Log.w(TAG, "Installed but failed to delete source temp file: ${candidateFile.absolutePath}") + // Both files are now on the same volume (templatesDir), so this is a cheap, + // same-directory move - renameTo() failing here (as opposed to across the + // temp/templates boundary above) would be unexpected, but fall back anyway. + if (!stagingFile.renameTo(destFile)) { + stagingFile.copyTo(destFile, overwrite = true) + if (!stagingFile.delete()) { + log.warn("Installed but failed to delete staging file: {}", stagingFile.name) } } ITemplateProvider.getInstance(reload = true) Unit }.onFailure { exception -> - Log.e(TAG, "Failed to install template collection: ${candidateFile.absolutePath}", exception) + log.error("Failed to install template collection: {}", candidateFile.name, exception) } } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index 9095ab2d23..972023b81f 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -2,7 +2,6 @@ package com.itsaky.androidide.viewmodels import android.content.ContentResolver import android.net.Uri -import android.util.Log import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -26,6 +25,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION +import org.slf4j.LoggerFactory import java.io.File import java.util.UUID @@ -40,7 +40,7 @@ class ExternalFileInstallViewModel( private val filesDir: File, ) : ViewModel() { private companion object { - private const val TAG = "ExternalFileInstallVM" + private val log = LoggerFactory.getLogger(ExternalFileInstallViewModel::class.java) private val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") // A cold OS-triggered launch of this activity can win the race against @@ -120,7 +120,7 @@ class ExternalFileInstallViewModel( withContext(NonCancellable + Dispatchers.IO) { deleteQuietlyBlocking(destination) } throw e } catch (e: Exception) { - Log.e(TAG, "Failed to copy incoming file", e) + log.error("Failed to copy incoming file", e) withContext(Dispatchers.IO) { deleteQuietlyBlocking(destination) } sendErrorAndFinish(R.string.msg_invalid_incoming_file) return@launch @@ -153,7 +153,7 @@ class ExternalFileInstallViewModel( ) { val info = templateCollectionRepository.inspectCollection(tempFile).getOrElse { exception -> - Log.w(TAG, "Invalid template collection file: ${tempFile.name}", exception) + log.warn("Invalid template collection file: {}", tempFile.name, exception) deleteQuietly(tempFile) sendErrorAndFinish(R.string.msg_template_invalid_file) return @@ -208,7 +208,7 @@ class ExternalFileInstallViewModel( // Deliberately don't delete tempFile or Finish here: the dialog the user was // just on (install-confirm / name-conflict / rename) stays open so they can // retry - e.g. pick a different name after a collision, or Overwrite instead. - Log.e(TAG, "Failed to install template collection", exception) + log.error("Failed to install template collection", exception) _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(R.string.msg_template_install_failed)) } _isInstalling.value = false diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index 54c18c73be..c33342f80e 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -17,7 +17,9 @@ import com.itsaky.androidide.ui.models.PluginOperation import com.itsaky.androidide.utils.EditorDecorationBridge import com.itsaky.androidide.utils.LastValueGate import com.itsaky.androidide.utils.UriFileImporter +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -353,6 +355,8 @@ class PluginManagerViewModel( deleteInstallSource(source) } } + } catch (e: CancellationException) { + throw e } catch (exception: Exception) { Log.e(TAG, "Error installing plugin from URI", exception) _uiEffect.trySend( @@ -366,7 +370,7 @@ class PluginManagerViewModel( } } finally { ownedTempFile?.let { file -> - withContext(Dispatchers.IO) { + withContext(NonCancellable + Dispatchers.IO) { if (file.exists()) { file.delete() } diff --git a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt index 6775d2764b..5b998f04ae 100644 --- a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt +++ b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt @@ -166,4 +166,41 @@ class TemplateCollectionRepositoryImplTest { assertThat(result.isFailure).isTrue() } + + @Test + fun `installCollection rejects a targetBaseName containing a path separator`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "../evil", overwrite = false) + + assertThat(result.isFailure).isTrue() + assertThat(File(templatesDir.parentFile, "evil.cgt").exists()).isFalse() + } + + @Test + fun `installCollection rejects a targetBaseName that is a bare traversal segment`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "..", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection preserves the existing collection if the incoming archive cannot be staged`() = + runTest { + val destination = File(templatesDir, "my-templates.cgt") + destination.writeText("stale but valid content") + // A candidate that no longer exists can't be renamed or copied into staging, so the + // staging step fails before destFile is ever touched. + val missingCandidate = File(tempFolder.newFolder(), "gone.cgt") + + val result = repository.installCollection(missingCandidate, "my-templates", overwrite = true) + + assertThat(result.isFailure).isTrue() + assertThat(destination.exists()).isTrue() + assertThat(destination.readText()).isEqualTo("stale but valid content") + } } diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt index 7c996b7131..f911eb4605 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt @@ -16,7 +16,6 @@ import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest -import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test @@ -50,17 +49,10 @@ class ExternalFileInstallViewModelTest { pluginRepository = pluginRepository, templateCollectionRepository = templateCollectionRepository, contentResolver = context.contentResolver, - filesDir = context.filesDir, + filesDir = tempFolder.root, ) } - @After - fun tearDown() { - // onReceived() copies into context.filesDir/temp - a real Robolectric app files dir, not - // covered by the tempFolder rule above, so it doesn't get cleaned up automatically. - File(context.filesDir, "temp").deleteRecursively() - } - private fun sourceUriFor( fileName: String, content: String = "dummy", diff --git a/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt new file mode 100644 index 0000000000..dc67a5ff99 --- /dev/null +++ b/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt @@ -0,0 +1,30 @@ +package com.itsaky.androidide.utils + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +class FileProviderUtilsTest { + @Test + fun `fileProviderAuthorityFor appends the fixed suffix to the package name`() { + assertThat(fileProviderAuthorityFor("com.itsaky.androidide")) + .isEqualTo("com.itsaky.androidide.providers.fileprovider") + } + + @Test + fun `fileProviderAuthorityFor is stable across different package names`() { + assertThat(fileProviderAuthorityFor("com.example.other")) + .isEqualTo("com.example.other.providers.fileprovider") + } + + @Test + fun `Context fileProviderAuthority delegates to the context's package name`() { + val context = mockk() + every { context.packageName } returns "com.itsaky.androidide" + + assertThat(context.fileProviderAuthority()) + .isEqualTo(fileProviderAuthorityFor("com.itsaky.androidide")) + } +} From 5e8043a3a703775866410db13b5d966241341682 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Sun, 16 Aug 2026 22:25:45 -0700 Subject: [PATCH 11/23] ADFA-4934: Fix findings from fresh CodeRabbit review on PR #1682 - Rethrow CancellationException in inspectCollection and installCollection (both used runCatching, which was swallowing it into a Result.failure). - Fix a retry-ability regression the previous commit's staging fix introduced: installCollection now copies (rather than moves) the candidate into staging and only deletes it after the whole install succeeds, so a failed install leaves the source file intact for the caller to retry with the same file. - Add KDoc to TemplateCollectionRepositoryImpl and FileProviderUtilsTest. - Broaden test coverage: uppercase-collision install/overwrite, the remaining invalid targetBaseName cases (backslash, ".", blank), byte- content assertions on install/overwrite, and a retry-ability test. Co-Authored-By: Claude Sonnet 5 --- .../TemplateCollectionRepositoryImpl.kt | 35 +++++--- .../TemplateCollectionRepositoryImplTest.kt | 81 ++++++++++++++++++- .../androidide/utils/FileProviderUtilsTest.kt | 1 + 3 files changed, 102 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt index d767ad4b01..c750b51b6b 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -17,6 +17,10 @@ import java.io.File * Implementation of [TemplateCollectionRepository]. Templates are pure data (a zip archive * copied into [Environment.TEMPLATES_DIR]) so, unlike plugins, installing one never requires an * app restart - [ITemplateProvider.getInstance] just needs to be reloaded. + * + * All suspend functions here hop to [Dispatchers.IO] internally, so callers don't need to. On + * failure, [installCollection] always leaves its `candidateFile` argument untouched (see that + * function's kdoc) so the caller can retry with the same file. */ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { private companion object { @@ -53,6 +57,7 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { templateNames = templates.map { it.templateNameStr }, ) }.onFailure { exception -> + if (exception is CancellationException) throw exception log.error("Failed to inspect template collection: {}", candidateFile.name, exception) } } @@ -69,6 +74,11 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { } } + /** + * Installs [candidateFile] as `.cgt` in [Environment.TEMPLATES_DIR]. On any + * failure (including a validation error), [candidateFile] is left untouched so the caller can + * retry - it's only deleted once the install has fully succeeded. + */ override suspend fun installCollection( candidateFile: File, targetBaseName: String, @@ -116,17 +126,14 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"") } - // Stage the incoming archive fully under templatesDir before touching destFile, - // so a failure while writing the new content never destroys the existing - // collection - only once the new file is confirmed on disk do we delete the old - // one and swap the staged file into place. + // Stage a copy of the incoming archive fully under templatesDir before touching + // destFile, so a failure while writing the new content never destroys the + // existing collection. candidateFile itself is deliberately left alone here (not + // moved/deleted) so that if anything below fails, the caller can retry the whole + // call with the same file - it's only deleted once the swap and the provider + // reload have both fully succeeded. val stagingFile = File(templatesDir, "${destFile.name}.tmp") - if (!candidateFile.renameTo(stagingFile)) { - candidateFile.copyTo(stagingFile, overwrite = true) - if (!candidateFile.delete()) { - log.warn("Installed but failed to delete source temp file: {}", candidateFile.name) - } - } + candidateFile.copyTo(stagingFile, overwrite = true) if (destFile.exists() && !destFile.delete()) { stagingFile.delete() @@ -135,7 +142,8 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { // Both files are now on the same volume (templatesDir), so this is a cheap, // same-directory move - renameTo() failing here (as opposed to across the - // temp/templates boundary above) would be unexpected, but fall back anyway. + // temp/templates boundary candidateFile itself would have to cross) would be + // unexpected, but fall back anyway. if (!stagingFile.renameTo(destFile)) { stagingFile.copyTo(destFile, overwrite = true) if (!stagingFile.delete()) { @@ -144,8 +152,13 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { } ITemplateProvider.getInstance(reload = true) + + if (!candidateFile.delete()) { + log.warn("Installed but failed to delete source temp file: {}", candidateFile.name) + } Unit }.onFailure { exception -> + if (exception is CancellationException) throw exception log.error("Failed to install template collection: {}", candidateFile.name, exception) } } diff --git a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt index 5b998f04ae..9d89fd940b 100644 --- a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt +++ b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt @@ -112,11 +112,14 @@ class TemplateCollectionRepositoryImplTest { fun `installCollection copies the archive into TEMPLATES_DIR and deletes the source`() = runTest { val cgt = buildCgt("Empty Activity") + val expectedBytes = cgt.readBytes() val result = repository.installCollection(cgt, "my-templates", overwrite = false) assertThat(result.isSuccess).isTrue() - assertThat(File(templatesDir, "my-templates.cgt").exists()).isTrue() + val installed = File(templatesDir, "my-templates.cgt") + assertThat(installed.exists()).isTrue() + assertThat(installed.readBytes()).isEqualTo(expectedBytes) assertThat(cgt.exists()).isFalse() } @@ -131,17 +134,43 @@ class TemplateCollectionRepositoryImplTest { assertThat(result.isFailure).isTrue() } + @Test + fun `installCollection without overwrite fails against an existing case-variant destination`() = + runTest { + File(templatesDir, "MyTemplates.CGT").writeText("existing") + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "mytemplates", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection with overwrite replaces an existing case-variant destination in place`() = + runTest { + val destination = File(templatesDir, "MyTemplates.CGT") + destination.writeText("stale content") + val cgt = buildCgt("Empty Activity") + val expectedBytes = cgt.readBytes() + + val result = repository.installCollection(cgt, "mytemplates", overwrite = true) + + assertThat(result.isSuccess).isTrue() + assertThat(destination.readBytes()).isEqualTo(expectedBytes) + } + @Test fun `installCollection with overwrite replaces the existing destination`() = runTest { val destination = File(templatesDir, "my-templates.cgt") destination.writeText("stale content") val cgt = buildCgt("Empty Activity") + val expectedBytes = cgt.readBytes() val result = repository.installCollection(cgt, "my-templates", overwrite = true) assertThat(result.isSuccess).isTrue() - assertThat(destination.readText()).isNotEqualTo("stale content") + assertThat(destination.readBytes()).isEqualTo(expectedBytes) } @Test @@ -188,13 +217,43 @@ class TemplateCollectionRepositoryImplTest { assertThat(result.isFailure).isTrue() } + @Test + fun `installCollection rejects a targetBaseName containing a backslash`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "evil\\name", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection rejects a bare dot targetBaseName`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, ".", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + + @Test + fun `installCollection rejects a blank targetBaseName`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, " ", overwrite = false) + + assertThat(result.isFailure).isTrue() + } + @Test fun `installCollection preserves the existing collection if the incoming archive cannot be staged`() = runTest { val destination = File(templatesDir, "my-templates.cgt") destination.writeText("stale but valid content") - // A candidate that no longer exists can't be renamed or copied into staging, so the - // staging step fails before destFile is ever touched. + // A candidate that no longer exists can't be copied into staging, so the staging + // step fails before destFile is ever touched. val missingCandidate = File(tempFolder.newFolder(), "gone.cgt") val result = repository.installCollection(missingCandidate, "my-templates", overwrite = true) @@ -203,4 +262,18 @@ class TemplateCollectionRepositoryImplTest { assertThat(destination.exists()).isTrue() assertThat(destination.readText()).isEqualTo("stale but valid content") } + + @Test + fun `installCollection leaves candidateFile untouched so the caller can retry after a failure`() = + runTest { + // A reserved-name failure happens before any file I-O, so candidateFile must still be + // exactly where the caller left it - this is the contract ExternalFileInstallViewModel + // relies on to keep the retry dialog usable after a failed install. + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "core", overwrite = true) + + assertThat(result.isFailure).isTrue() + assertThat(cgt.exists()).isTrue() + } } diff --git a/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt index dc67a5ff99..f9f10926c1 100644 --- a/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt +++ b/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt @@ -6,6 +6,7 @@ import io.mockk.every import io.mockk.mockk import org.junit.Test +/** Every `content://` Uri this app mints or checks must agree on the same FileProvider authority string. */ class FileProviderUtilsTest { @Test fun `fileProviderAuthorityFor appends the fixed suffix to the package name`() { From 6fbf4c121dd6b5dee063af679d4560b0e6a314ab Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 03:58:39 -0700 Subject: [PATCH 12/23] ADFA-4934: Fix findings from max-effort code review of PR #1682 - PluginRepositoryImpl: case-insensitive .cgp extension check, fixing permanent data loss for uppercase-named plugin files. - PluginManagerViewModel: await the first loadPlugins() completion before checking for a same-ID conflict, closing a race that could skip the signature check on a cold-started install; only ever delete a forwarded LocalFile temp copy on decline/failure, never a user-picked ContentUri (matches the "delete after install" checkbox's success-only meaning); corrected a comment that overstated the deletion invariant. - PluginManagerActivity: route back-press/tap-outside through CancelPendingInstall on both install dialogs, so a forwarded temp file is never leaked by a silently-cancelable dialog. - TemplateCollectionRepositoryImpl: replace the existing collection via a backup-swap-restore instead of delete-then-write, so a failed final copy can no longer destroy it; give staging/backup files unique names so concurrent installs of the same collection don't race on the same path. - TemplateProviderImpl: case-insensitive .cgt scan, matching the repository's case-insensitive install/collision handling. - AndroidManifest: add keyboard/keyboardHidden/navigation to both install-flow activities' configChanges, closing the same dialog-dropped-on-recreation class of bug for another config axis. - ExternalFileInstallScreen: disable dismiss/cancel on all three dialogs while an install is in flight, so a fast tap can't race a delete against the in-progress install; new Flashbar await-shown helpers replace a fixed delay with the real animation-complete signal before finishing the activity. - ExternalFileInstallViewModel: widen the setup-wait budget from ~2.7s to ~8s to better match a real cold-start's unbounded init chain. Deferred: TemplateProviderImpl's per-archive parse errors are still only logged, not surfaced to installCollection's caller - closing that requires exposing per-archive load state across the templates-api/impl module boundary, which is disproportionate for this PR relative to how speculative the failure mode is. Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 16 ++-- .../activities/ExternalFileInstallScreen.kt | 29 +++--- .../activities/PluginManagerActivity.kt | 11 +++ .../repositories/PluginRepositoryImpl.kt | 3 +- .../TemplateCollectionRepositoryImpl.kt | 36 +++++--- .../ExternalFileInstallViewModel.kt | 30 +++---- .../viewmodels/PluginManagerViewModel.kt | 47 ++++++++-- .../TemplateCollectionRepositoryImplTest.kt | 23 +++++ .../androidide/utils/FlashbarActivityUtils.kt | 88 +++++++++++++++--- .../itsaky/androidide/utils/FlashbarUtils.kt | 19 ++++ .../templates/impl/TemplateProviderImpl.kt | 89 +++++++++---------- 11 files changed, 281 insertions(+), 110 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 321dbdee04..0e460d9034 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -104,13 +104,14 @@ - + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|locale|fontScale|density|keyboard|keyboardHidden|navigation" /> - diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt index dc7f0713d4..acac8606bb 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt @@ -27,8 +27,8 @@ import com.itsaky.androidide.repositories.TemplateCollectionRepository import com.itsaky.androidide.ui.compose.longPressTooltip import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent -import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.utils.flashErrorAwaitShown +import com.itsaky.androidide.utils.flashSuccessAwaitShown import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel import java.io.File @@ -83,11 +83,15 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { // Deliberately doesn't touch dialogState: on an install failure the ViewModel // sends ShowError without a following Finish, so whichever dialog is open // (install-confirm / name-conflict / rename) stays open for the user to retry. - flashError(context.getString(effect.messageResId, *effect.formatArgs.toTypedArray())) + // Awaits the bar's entrance animation instead of returning immediately: this + // suspends the collect{} loop above, so a Finish effect buffered right after + // this one (see sendErrorAndFinish()) isn't processed - and doesn't tear the + // window down - until the message has actually finished appearing. + flashErrorAwaitShown(context.getString(effect.messageResId, *effect.formatArgs.toTypedArray())) } is ExternalFileInstallUiEffect.ShowSuccess -> { - flashSuccess(context.getString(effect.messageResId)) + flashSuccessAwaitShown(context.getString(effect.messageResId)) } is ExternalFileInstallUiEffect.Finish -> { @@ -176,7 +180,10 @@ private fun InstallConfirmationDialog( onDismiss: () -> Unit, ) { AlertDialog( - onDismissRequest = onDismiss, + // Gated on installEnabled (== !isInstalling): once Install is tapped, the ViewModel + // starts copying/replacing tempFile on viewModelScope - dismissing here would race + // IgnoreTemplateInstall's own delete of that same file against the in-progress install. + onDismissRequest = { if (installEnabled) onDismiss() }, title = { Text( stringResource(R.string.title_install_template_collection), @@ -196,7 +203,7 @@ private fun InstallConfirmationDialog( TextButton(onClick = onInstall, enabled = installEnabled) { Text(stringResource(R.string.btn_install)) } }, dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) } }, ) } @@ -210,7 +217,7 @@ private fun NameConflictDialog( onDismiss: () -> Unit, ) { AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = { if (installEnabled) onDismiss() }, title = { Text( stringResource(R.string.title_template_already_installed), @@ -231,8 +238,8 @@ private fun NameConflictDialog( }, dismissButton = { Row { - TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } - TextButton(onClick = onRename) { Text(stringResource(R.string.btn_rename_and_install)) } + TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) } + TextButton(onClick = onRename, enabled = installEnabled) { Text(stringResource(R.string.btn_rename_and_install)) } } }, ) @@ -262,7 +269,7 @@ private fun RenameDialog( } AlertDialog( - onDismissRequest = onDismiss, + onDismissRequest = { if (installEnabled) onDismiss() }, title = { Text( stringResource(R.string.btn_rename_and_install), @@ -289,7 +296,7 @@ private fun RenameDialog( } }, dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) } }, ) } diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index cde32ed617..3e32c1e5f5 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -340,6 +340,11 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(source, deleteSourceAfterInstall = true)) }.setNegativeButton(android.R.string.cancel) { _, _ -> viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source, deleteSourceAfterInstall = true)) + }.setOnCancelListener { + // Dialogs default to cancelable=true: back-press/tap-outside must be treated + // the same as the negative button, or the forwarded temp file behind [source] + // is leaked forever with no other cleanup path. + viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source, deleteSourceAfterInstall = true)) }.show() return } @@ -374,6 +379,12 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { viewModel.onEvent( PluginManagerUiEvent.CancelPendingInstall(effect.source, effect.deleteSourceAfterInstall), ) + }.setOnCancelListener { + // Same reasoning as showInstallConfirmation()'s onCancelListener: back-press must + // route through CancelPendingInstall too, or a forwarded source's temp file leaks. + viewModel.onEvent( + PluginManagerUiEvent.CancelPendingInstall(effect.source, effect.deleteSourceAfterInstall), + ) }.show() } diff --git a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt index 701acda5c9..8c3c29860e 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt @@ -142,7 +142,8 @@ class PluginRepositoryImpl( Log.w(TAG, "Error uninstalling existing plugin: ${e.message}") } - val fileExtension = if (pluginFile.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION")) ".$PLUGIN_ARCHIVE_EXTENSION" else ".apk" + val fileExtension = + if (pluginFile.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true)) ".$PLUGIN_ARCHIVE_EXTENSION" else ".apk" val finalFileName = "${pluginId}$fileExtension" if (!pluginsDir.exists()) { diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt index c750b51b6b..817e9e2363 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -12,6 +12,7 @@ import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import org.adfa.constants.TEMPLATE_CORE_ARCHIVE import org.slf4j.LoggerFactory import java.io.File +import java.util.UUID /** * Implementation of [TemplateCollectionRepository]. Templates are pure data (a zip archive @@ -131,24 +132,39 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { // existing collection. candidateFile itself is deliberately left alone here (not // moved/deleted) so that if anything below fails, the caller can retry the whole // call with the same file - it's only deleted once the swap and the provider - // reload have both fully succeeded. - val stagingFile = File(templatesDir, "${destFile.name}.tmp") + // reload have both fully succeeded. The staging (and backup) filenames carry a + // random suffix so two concurrent installCollection() calls targeting the same + // destFile never race on the same intermediate path. + val stagingFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.tmp") candidateFile.copyTo(stagingFile, overwrite = true) - if (destFile.exists() && !destFile.delete()) { + // Back up (rather than delete) any existing destFile, so it can be put back if + // the swap below fails for any reason - the existing collection is only ever + // removed once the new one is confirmed successfully in its place. + val hadExisting = destFile.exists() + val backupFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.bak") + if (hadExisting && !destFile.renameTo(backupFile)) { stagingFile.delete() - throw IllegalStateException("Failed to replace existing file: ${destFile.name}") + throw IllegalStateException("Failed to back up existing file before replacing: ${destFile.name}") } // Both files are now on the same volume (templatesDir), so this is a cheap, // same-directory move - renameTo() failing here (as opposed to across the // temp/templates boundary candidateFile itself would have to cross) would be - // unexpected, but fall back anyway. - if (!stagingFile.renameTo(destFile)) { - stagingFile.copyTo(destFile, overwrite = true) - if (!stagingFile.delete()) { - log.warn("Installed but failed to delete staging file: {}", stagingFile.name) - } + // unexpected, but fall back to a copy anyway. + val swapSucceeded = + stagingFile.renameTo(destFile) || + runCatching { stagingFile.copyTo(destFile, overwrite = true) }.isSuccess + + if (!swapSucceeded) { + if (hadExisting) backupFile.renameTo(destFile) + stagingFile.delete() + throw IllegalStateException("Failed to replace existing file: ${destFile.name}") + } + + stagingFile.delete() + if (hadExisting && backupFile.exists() && !backupFile.delete()) { + log.warn("Installed but failed to delete backup file: {}", backupFile.name) } ITemplateProvider.getInstance(reload = true) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index 972023b81f..d1661a7db2 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -43,18 +43,15 @@ class ExternalFileInstallViewModel( private val log = LoggerFactory.getLogger(ExternalFileInstallViewModel::class.java) private val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]") - // A cold OS-triggered launch of this activity can win the race against - // IDEApplication's async setup (device-unlock -> CredentialProtectedApplicationLoader.load()), - // so isPluginManagerAvailable()/isTemplatesFeatureAvailable() are polled briefly - // instead of failing on the very first check. - private const val SETUP_WAIT_ATTEMPTS = 10 - private const val SETUP_WAIT_INTERVAL_MS = 300L - - // Gives Flashbar's async layout-triggered entrance animation (see FlashbarContainerView's - // afterMeasured{}) a chance to actually start before Finish tears the window down - - // without this, ShowError/ShowSuccess sent immediately before Finish can be dismissed - // before ever rendering. - private const val FLASH_MESSAGE_DELAY_MS = 300L + // A cold OS-triggered launch of this activity can win the race against IDEApplication's + // async setup (device-unlock -> CredentialProtectedApplicationLoader.load(), which itself + // chains a long, unbounded sequence of Sentry/Firebase/EventBus/WorkManager/Termux/plugin + // init work), so isPluginManagerAvailable()/isTemplatesFeatureAvailable() are polled + // instead of failing on the very first check. ~8s total gives real cold starts a + // realistic margin; there's no true completion signal to await instead (see ADFA-4934 + // code review notes), so this remains a bounded-poll approximation, not a hard guarantee. + private const val SETUP_WAIT_ATTEMPTS = 20 + private const val SETUP_WAIT_INTERVAL_MS = 400L } // Buffered (not rendezvous): onReceived() runs via Dispatchers.Main.immediate right after @@ -201,8 +198,10 @@ class ExternalFileInstallViewModel( templateCollectionRepository .installCollection(tempFile, targetBaseName, overwrite) .onSuccess { + // The Screen suspends on ShowSuccess until the flashbar's entrance animation + // actually finishes (flashSuccessAwaitShown) before processing the next + // buffered effect, so Finish here doesn't need its own artificial delay. _uiEffect.trySend(ExternalFileInstallUiEffect.ShowSuccess(R.string.msg_template_installed)) - delay(FLASH_MESSAGE_DELAY_MS) _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) }.onFailure { exception -> // Deliberately don't delete tempFile or Finish here: the dialog the user was @@ -228,11 +227,12 @@ class ExternalFileInstallViewModel( fun sanitizeBaseName(rawName: String): String = rawName.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { "templates" } - private suspend fun sendErrorAndFinish( + private fun sendErrorAndFinish( @StringRes messageResId: Int, ) { + // See confirmTemplateInstall()'s onSuccess: the Screen suspends on ShowError until the + // flashbar is actually shown before processing Finish, so no delay is needed here either. _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(messageResId)) - delay(FLASH_MESSAGE_DELAY_MS) _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index c33342f80e..109d5aad9b 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -18,6 +18,7 @@ import com.itsaky.androidide.utils.EditorDecorationBridge import com.itsaky.androidide.utils.LastValueGate import com.itsaky.androidide.utils.UriFileImporter import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.channels.Channel @@ -52,6 +53,12 @@ class PluginManagerViewModel( // still shows the dialog instead of silently dropping the forwarded install. private val pendingInstallGate = LastValueGate() + // Completed once the first loadPlugins() call (from init{}) has concluded, successfully or + // not. resolveInstallConflict() awaits this before consulting _uiState.value.plugins, so an + // install confirmed immediately after a cold start can't race the async plugin-list load and + // skip the same-ID signature check by seeing an still-empty list. + private val initialLoadCompleted = CompletableDeferred() + /** See [pendingInstallGate] for why this, rather than an Activity `savedInstanceState` * check, is what correctly distinguishes "already shown after a rotation" from "never shown * because the process died". */ @@ -117,7 +124,12 @@ class PluginManagerViewModel( } is PluginManagerUiEvent.CancelPendingInstall -> { - if (event.deleteSourceAfterInstall) { + // Only a forwarded LocalFile (our own disposable temp copy) is cleaned up here - + // nothing was installed, so a user-picked ContentUri source is never touched on + // decline, regardless of deleteSourceAfterInstall (that flag only ever governs + // deletion after a *successful* install, matching its "delete after install" + // label). + if (event.source is PluginInstallSource.LocalFile) { viewModelScope.launch { deleteInstallSource(event.source) } } } @@ -138,6 +150,7 @@ class PluginManagerViewModel( private fun loadPlugins() { if (!pluginRepository.isPluginManagerAvailable()) { _uiState.update { it.copy(isPluginManagerAvailable = false) } + initialLoadCompleted.complete(Unit) return } @@ -173,6 +186,9 @@ class PluginManagerViewModel( EditorDecorationBridge.refresh() _currentOperation.value = PluginOperation.None + // A no-op if already completed by an earlier loadPlugins() call - only the first + // call's outcome matters for initialLoadCompleted's purpose. + initialLoadCompleted.complete(Unit) } } @@ -294,13 +310,25 @@ class PluginManagerViewModel( _currentOperation.value = PluginOperation.Installing _uiState.update { it.copy(isInstalling = true) } - // Only a copy this function made itself (ContentUri case) is unconditionally safe to - // delete below - a forwarded LocalFile is the caller's file, and its lifecycle is - // governed by deleteSourceAfterInstall/deleteInstallSource instead. + // ownedTempFile (the ContentUri case's own temp copy) is what the `finally` block + // below cleans up unconditionally. Note pluginRepository.installPluginFromFile() + // itself unconditionally deletes whatever `pluginFile` it's given once that's copied + // into the plugins directory - that's pre-existing behavior this function doesn't + // control (it also affects InstallFileAction.kt's direct callers). What + // deleteSourceAfterInstall/deleteInstallSource governs below is the *original* + // source's lifecycle instead: a user-picked ContentUri is only ever deleted after a + // successful install (see the onSuccess/onFailure split below), while a forwarded + // LocalFile temp copy is always cleaned up regardless of outcome. var ownedTempFile: File? = null var pluginFile: File? = null try { + if (checkConflict) { + // See initialLoadCompleted's kdoc: guarantees _uiState.value.plugins reflects + // the real installed set before resolveInstallConflict() checks it below. + initialLoadCompleted.await() + } + pluginFile = when (source) { is PluginInstallSource.LocalFile -> { @@ -351,7 +379,10 @@ class PluginManagerViewModel( listOf(exception.message ?: ""), ), ) - if (deleteSourceAfterInstall) { + // A failed install deletes nothing but our own disposable temp copy - a + // user-picked ContentUri is preserved so they can retry, matching + // deleteSourceAfterInstall's "delete after install [succeeds]" meaning. + if (source is PluginInstallSource.LocalFile) { deleteInstallSource(source) } } @@ -365,7 +396,7 @@ class PluginManagerViewModel( listOf(exception.message ?: ""), ), ) - if (deleteSourceAfterInstall) { + if (source is PluginInstallSource.LocalFile) { deleteInstallSource(source) } } finally { @@ -391,7 +422,7 @@ class PluginManagerViewModel( if (incoming == null) { Log.w(TAG, "Failed to read plugin metadata from ${pluginFile.name}; aborting install") _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file)) - if (deleteSourceAfterInstall) deleteInstallSource(source) + if (source is PluginInstallSource.LocalFile) deleteInstallSource(source) return true } @@ -411,7 +442,7 @@ class PluginManagerViewModel( listOf(existing.metadata.name), ), ) - if (deleteSourceAfterInstall) deleteInstallSource(source) + if (source is PluginInstallSource.LocalFile) deleteInstallSource(source) return true } diff --git a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt index 9d89fd940b..555511efb1 100644 --- a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt +++ b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt @@ -276,4 +276,27 @@ class TemplateCollectionRepositoryImplTest { assertThat(result.isFailure).isTrue() assertThat(cgt.exists()).isTrue() } + + @Test + fun `installCollection leaves no stray staging or backup files behind on a fresh install`() = + runTest { + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "my-templates", overwrite = false) + + assertThat(result.isSuccess).isTrue() + assertThat(templatesDir.listFiles()?.map { it.name }).containsExactly("my-templates.cgt") + } + + @Test + fun `installCollection leaves no stray staging or backup files behind on an overwrite`() = + runTest { + File(templatesDir, "my-templates.cgt").writeText("stale content") + val cgt = buildCgt("Empty Activity") + + val result = repository.installCollection(cgt, "my-templates", overwrite = true) + + assertThat(result.isSuccess).isTrue() + assertThat(templatesDir.listFiles()?.map { it.name }).containsExactly("my-templates.cgt") + } } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt index 532fd8f59e..58aa1a1827 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt @@ -34,13 +34,19 @@ import com.itsaky.androidide.tasks.runOnUiThread import com.itsaky.androidide.utils.FlashType.ERROR import com.itsaky.androidide.utils.FlashType.INFO import com.itsaky.androidide.utils.FlashType.SUCCESS +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull const val DURATION_SHORT = 2000L const val DURATION_LONG = 3500L const val DURATION_INDEFINITE = Flashbar.DURATION_INDEFINITE +/** Safety net for [Flashbar.OnBarShowListener.onShown] never firing - callers awaiting it are + * never blocked indefinitely (e.g. if there's no foreground activity to actually show a bar). */ +private const val FLASH_SHOWN_TIMEOUT_MS = 3000L + val COLOR_SUCCESS = Color.parseColor("#4CAF50") val COLOR_ERROR = Color.parseColor("#f44336") const val COLOR_INFO = Color.DKGRAY @@ -60,28 +66,36 @@ private fun Activity.showFlashBar( gravity: Flashbar.Gravity = TOP, duration: Long = Flashbar.DURATION_SHORT, ) { - val builder = flashbarBuilder(gravity, duration) - .applyIcon(iconType) + val builder = + flashbarBuilder(gravity, duration) + .applyIcon(iconType) - // Add a close button if the flashbar is an indefinite error - if (duration == DURATION_INDEFINITE && iconType == IconType.ERROR) { - builder.positiveActionText(getString(R.string.dismiss)) - builder.positiveActionTapListener { it.dismiss() } - } + // Add a close button if the flashbar is an indefinite error + if (duration == DURATION_INDEFINITE && iconType == IconType.ERROR) { + builder.positiveActionText(getString(R.string.dismiss)) + builder.positiveActionTapListener { it.dismiss() } + } when (msg) { - null -> return - is Int -> + null -> { + return + } + + is Int -> { builder .message(msg) .showOnUiThread() + } - is String -> - builder + is String -> { + builder .message(msg) .showOnUiThread() + } - else -> throw IllegalArgumentException("Message must be String or Int resource") + else -> { + throw IllegalArgumentException("Message must be String or Int resource") + } } } @@ -128,6 +142,56 @@ fun Activity.flashError(msg: String?) = showFlashBar(msg, IconType.ERROR, durati fun Activity.flashInfo(msg: String?) = showFlashBar(msg, IconType.INFO) +/** + * Like [showFlashBar], but suspends until the bar's entrance animation has actually finished (or + * [FLASH_SHOWN_TIMEOUT_MS] elapses) instead of firing-and-forgetting - for callers (e.g. a + * one-shot screen about to finish()) that need the message to be visible before proceeding, + * rather than guessing a fixed delay that may or may not outlast the real animation. + */ +private suspend fun Activity.showFlashBarAwaitShown( + msg: Any?, + iconType: IconType, + gravity: Flashbar.Gravity = TOP, + duration: Long = Flashbar.DURATION_SHORT, +) { + val builder = flashbarBuilder(gravity, duration).applyIcon(iconType) + + if (duration == DURATION_INDEFINITE && iconType == IconType.ERROR) { + builder.positiveActionText(getString(R.string.dismiss)) + builder.positiveActionTapListener { it.dismiss() } + } + + when (msg) { + null -> return + is Int -> builder.message(msg) + is String -> builder.message(msg) + else -> throw IllegalArgumentException("Message must be String or Int resource") + } + + val shown = CompletableDeferred() + builder.barShowListener( + object : Flashbar.OnBarShowListener { + override fun onShowing(bar: Flashbar) = Unit + + override fun onShowProgress( + bar: Flashbar, + progress: Float, + ) = Unit + + override fun onShown(bar: Flashbar) { + shown.complete(Unit) + } + }, + ) + + runOnUiThread { builder.build().show() } + withTimeoutOrNull(FLASH_SHOWN_TIMEOUT_MS) { shown.await() } +} + +suspend fun Activity.flashSuccessAwaitShown(msg: String?) = showFlashBarAwaitShown(msg, IconType.SUCCESS) + +suspend fun Activity.flashErrorAwaitShown(msg: String?) = showFlashBarAwaitShown(msg, IconType.ERROR, duration = DURATION_INDEFINITE) + fun Activity.flashSuccess( @StringRes msg: Int, ) = showFlashBar(msg, IconType.SUCCESS) diff --git a/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt index 6028135fa9..1d951a5495 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt @@ -48,10 +48,20 @@ fun flashSuccess( withActivity { flashSuccess(msg) } } +/** Suspends until the success bar has actually finished its entrance animation - see [Activity.flashSuccessAwaitShown]. */ +suspend fun flashSuccessAwaitShown(msg: String?) { + withActivitySuspend { flashSuccessAwaitShown(msg) } +} + fun flashError(msg: String?) { withActivity { flashError(msg) } } +/** Suspends until the error bar has actually finished its entrance animation - see [Activity.flashErrorAwaitShown]. */ +suspend fun flashErrorAwaitShown(msg: String?) { + withActivitySuspend { flashErrorAwaitShown(msg) } +} + fun flashError( @StringRes msg: Int, ) { @@ -78,6 +88,15 @@ private inline fun withActivity(action: Activity.() -> T?): T? = null } +private suspend inline fun withActivitySuspend(crossinline action: suspend Activity.() -> Unit) { + val activity = BaseApplication.baseInstance.foregroundActivity + if (activity == null) { + ILogger.ROOT.warn("Cannot show flashbar message. Cannot get top activity.") + return + } + activity.action() +} + /** The type of flashbar message. */ enum class FlashType { ERROR, diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt index 1acbe6a53d..2ba4aa2d76 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt @@ -24,10 +24,8 @@ import com.itsaky.androidide.templates.R import com.itsaky.androidide.templates.Template import com.itsaky.androidide.templates.impl.zip.ZipRecipeExecutor import com.itsaky.androidide.templates.impl.zip.ZipTemplateReader - -import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import com.itsaky.androidide.utils.Environment.TEMPLATES_DIR - +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import org.slf4j.LoggerFactory import java.util.zip.ZipFile @@ -39,56 +37,55 @@ import java.util.zip.ZipFile @Suppress("unused") @AutoService(ITemplateProvider::class) class TemplateProviderImpl : ITemplateProvider { + companion object { + private val log = LoggerFactory.getLogger(TemplateProviderImpl::class.java) + } - companion object { - private val log = LoggerFactory.getLogger(TemplateProviderImpl::class.java) - } - - private val templates = mutableMapOf>() - val warnings: MutableList = mutableListOf() + private val templates = mutableMapOf>() + val warnings: MutableList = mutableListOf() - init { - reload() - } + init { + reload() + } - private fun initializeTemplates() { - val folder = TEMPLATES_DIR - val list = folder.listFiles { file -> file.extension == TEMPLATE_ARCHIVE_EXTENSION } ?: return + private fun initializeTemplates() { + val folder = TEMPLATES_DIR + val list = folder.listFiles { file -> file.extension.equals(TEMPLATE_ARCHIVE_EXTENSION, ignoreCase = true) } ?: return - for (zipFile in list) { - try { - val zipTemplates = ZipTemplateReader.read(zipFile, warnings) { json, params, path, data, defModule -> - ZipRecipeExecutor({ ZipFile(zipFile) }, json, params, path, data, defModule) - } + for (zipFile in list) { + try { + val zipTemplates = + ZipTemplateReader.read(zipFile, warnings) { json, params, path, data, defModule -> + ZipRecipeExecutor({ ZipFile(zipFile) }, json, params, path, data, defModule) + } - for (t in zipTemplates) { - templates[t.templateId] = t - } - } catch (e: Exception) { - warnings.add(TemplateWarning( - R.string.template_read_error_archive_load, - listOf(zipFile, e.message))) - log.error("Failed to load template from archive: $zipFile", e) - } - } - } + for (t in zipTemplates) { + templates[t.templateId] = t + } + } catch (e: Exception) { + warnings.add( + TemplateWarning( + R.string.template_read_error_archive_load, + listOf(zipFile, e.message), + ), + ) + log.error("Failed to load template from archive: $zipFile", e) + } + } + } - override fun getTemplates(): List> { - return ImmutableList.copyOf(templates.values) - } + override fun getTemplates(): List> = ImmutableList.copyOf(templates.values) - override fun getTemplate(templateId: String): Template<*>? { - return templates[templateId] - } + override fun getTemplate(templateId: String): Template<*>? = templates[templateId] - override fun reload() { - release() - warnings.clear() - initializeTemplates() - } + override fun reload() { + release() + warnings.clear() + initializeTemplates() + } - override fun release() { - templates.forEach { it.value.release() } - templates.clear() - } + override fun release() { + templates.forEach { it.value.release() } + templates.clear() + } } From 0d899a75abb66cdd287b6d5957007e5b4fc531c8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 05:50:39 -0700 Subject: [PATCH 13/23] ADFA-4934: Fix findings from high-effort code review of PR #1682 - TemplateCollectionRepositoryImpl: escalate (rather than discard) a failed backup restore after a swap failure, and no longer report a spurious install failure when only the post-swap provider reload throws - the file swap is the operation's real postcondition. - ExternalFileInstallViewModel: cap suggestUniqueBaseName's search so a pathological repository can't hang the Rename dialog forever. - New InstallTempFiles util: shared filesDir/temp staging (extracted from near-identical code in ExternalFileInstallViewModel and PluginManagerViewModel's ContentUri branch) that also sweeps hour-old orphans - covers a temp file left behind if a forwarded .cgp's hand-off to PluginManagerActivity never completes. - FlashbarActivityUtils: extracted a shared configureFlashbar() helper so showFlashBar() and showFlashBarAwaitShown() can't silently diverge in their builder setup. - AndroidManifest.xml: documented two known, accepted limitations rather than fixing them - pathPattern can't match a mixed-case extension without an disproportionate enumeration of every case permutation, and suppressing recreation on uiMode/locale/etc. for dialog continuity means already-inflated View content can look stale until back-and-return (narrower on the Compose screen, which recomposes reactively on those axes). Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 32 +++++++-- .../TemplateCollectionRepositoryImpl.kt | 35 +++++++++- .../androidide/utils/InstallTempFiles.kt | 36 ++++++++++ .../ExternalFileInstallViewModel.kt | 12 ++-- .../viewmodels/PluginManagerViewModel.kt | 10 ++- .../ExternalFileInstallViewModelTest.kt | 12 ++++ .../androidide/utils/FlashbarActivityUtils.kt | 66 ++++++++----------- 7 files changed, 146 insertions(+), 57 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0e460d9034..be8009cd0a 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -108,7 +108,12 @@ (not just orientation etc.) because a recreation mid-dialog (e.g. a system dark/light auto-switch, or attaching/detaching a hardware keyboard) would otherwise dismiss the forwarded-install dialog while the ViewModel's one-shot "already handled" guard - (markPendingInstallHandled) silently blocks it from ever being shown again - ADFA-4934. --> + (markPendingInstallHandled) silently blocks it from ever being shown again - ADFA-4934. + Accepted tradeoff: since the Activity isn't recreated for these axes and + onConfigurationChanged() isn't overridden either, already-inflated view text/theme + (e.g. a title set once in onCreate(), or day/night-themed colors) can look stale + until the user backs out and re-enters - dialog continuity was judged more important + than instant re-theming for this one screen. --> @@ -139,18 +144,31 @@ re-exported from Windows tools) produce uppercase extensions. android:host="*" is required alongside pathPattern - the manifest matcher only evaluates pathPattern when host is also present. - Known limitation, not fixable via manifest matching: a sender whose content:// Uri - path never carries the filename/extension at all (some email providers' attachment - Uris look like content://.../message_attachment/12345/0/ATTACHMENT/false) can't match - a pathPattern-based filter regardless of type. The only alternative - a pathPattern- + Known limitations, not fixable via manifest matching: + (1) a sender whose content:// Uri path never carries the filename/extension at all + (some email providers' attachment Uris look like + content://.../message_attachment/12345/0/ATTACHMENT/false) can't match a + pathPattern-based filter regardless of type. The only alternative - a pathPattern- less, mimeType="*/*" filter - would register this app as a candidate handler for every file view intent on the device, which is a worse tradeoff than missing those - senders. --> + senders. + (2) only the fully-lowercase and fully-UPPERCASE forms are covered per extension - + a mixed-case extension (e.g. "Plugin.Cgp") matches neither, since pathPattern's + matcher supports only literal characters, '.', and '*' (no character classes), and + exhaustively enumerating every case permutation (2^3 per 3-letter extension, times + two extensions, times three filters below) would bloat this manifest far out of + proportion to how rare a genuinely mixed-case sender is in practice (real senders + observed so far are consistently either all-lowercase or Windows-style + all-UPPERCASE). --> + silently blocks the effect from ever being resent - ADFA-4934. Since this screen is + Compose (unlike PluginManagerActivity), content reading LocalConfiguration/ + stringResource/isSystemInDarkTheme() still recomposes reactively on these axes even + without an Activity recreation, so the same "stale until back-and-return" tradeoff is + narrower here than on the View-based screen. --> _.` file under `filesDir/temp`. */ + fun newTempFile( + filesDir: File, + prefix: String, + extension: String, + ): File { + val tempDir = File(filesDir, "temp").apply { mkdirs() } + sweepStale(tempDir) + return File(tempDir, "${prefix}_${UUID.randomUUID()}.$extension") + } + + private fun sweepStale(tempDir: File) { + val cutoff = System.currentTimeMillis() - MAX_AGE_MS + tempDir.listFiles()?.forEach { file -> + if (file.lastModified() < cutoff) { + file.delete() + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index d1661a7db2..5fa6f91606 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -10,6 +10,7 @@ import com.itsaky.androidide.repositories.TemplateCollectionRepository import com.itsaky.androidide.resources.R import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent +import com.itsaky.androidide.utils.InstallTempFiles import com.itsaky.androidide.utils.LastValueGate import com.itsaky.androidide.utils.UriFileImporter import kotlinx.coroutines.CancellationException @@ -27,7 +28,6 @@ import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import org.slf4j.LoggerFactory import java.io.File -import java.util.UUID /** * Handles a `.cgp`/`.cgt` file opened from outside the app (e.g. an email attachment), backing @@ -52,6 +52,11 @@ class ExternalFileInstallViewModel( // code review notes), so this remains a bounded-poll approximation, not a hard guarantee. private const val SETUP_WAIT_ATTEMPTS = 20 private const val SETUP_WAIT_INTERVAL_MS = 400L + + // Bounds suggestUniqueBaseName()'s search - a pathological repository (or a huge run of + // pre-existing "foo (2)", "foo (3)", ... collections) must not hang the Rename dialog + // forever waiting for a free name. + private const val MAX_SUGGESTION_ATTEMPTS = 50 } // Buffered (not rendezvous): onReceived() runs via Dispatchers.Main.immediate right after @@ -102,8 +107,7 @@ class ExternalFileInstallViewModel( return@launch } - val tempDir = File(filesDir, "temp").apply { mkdirs() } - val destination = File(tempDir, "incoming_${UUID.randomUUID()}.$extension") + val destination = InstallTempFiles.newTempFile(filesDir, "incoming", extension) val tempFile = try { @@ -218,7 +222,7 @@ class ExternalFileInstallViewModel( suspend fun suggestUniqueBaseName(baseName: String): String { var candidate = baseName var suffix = 2 - while (templateCollectionRepository.findExistingCollision(candidate) != null) { + while (suffix <= MAX_SUGGESTION_ATTEMPTS && templateCollectionRepository.findExistingCollision(candidate) != null) { candidate = "$baseName ($suffix)" suffix++ } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index 109d5aad9b..66d03824ad 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -15,6 +15,7 @@ import com.itsaky.androidide.ui.models.PluginManagerUiEvent import com.itsaky.androidide.ui.models.PluginManagerUiState import com.itsaky.androidide.ui.models.PluginOperation import com.itsaky.androidide.utils.EditorDecorationBridge +import com.itsaky.androidide.utils.InstallTempFiles import com.itsaky.androidide.utils.LastValueGate import com.itsaky.androidide.utils.UriFileImporter import kotlinx.coroutines.CancellationException @@ -31,7 +32,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import java.io.File -import java.util.UUID /** * ViewModel for the Plugin Manager screen @@ -340,13 +340,11 @@ class PluginManagerViewModel( val fileName = UriFileImporter.getDisplayName(contentResolver, source.uri) val extension = if (fileName?.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true) == true) { - ".$PLUGIN_ARCHIVE_EXTENSION" + PLUGIN_ARCHIVE_EXTENSION } else { - ".apk" + "apk" } - val tempFileName = "temp_plugin_${UUID.randomUUID()}$extension" - val tempDir = File(filesDir, "temp").apply { mkdirs() } - val tempFile = File(tempDir, tempFileName) + val tempFile = InstallTempFiles.newTempFile(filesDir, "temp_plugin", extension) UriFileImporter.copyUriToFile(contentResolver, source.uri, tempFile) { Exception("Cannot open file") diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt index f911eb4605..663285eaa5 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt @@ -192,6 +192,18 @@ class ExternalFileInstallViewModelTest { assertThat(suggested).isEqualTo("foo (3)") } + @Test + fun `suggestUniqueBaseName gives up after a bounded number of attempts`() = + runTest { + // A pathological repository that always reports a collision must not hang this + // suspend function forever. + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns "always-taken" + + val suggested = viewModel.suggestUniqueBaseName("foo") + + assertThat(suggested).isEqualTo("foo (50)") + } + private fun stubPluginManagerAvailable(available: Boolean) { every { pluginRepository.isPluginManagerAvailable() } returns available } diff --git a/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt index 58aa1a1827..67bfcec141 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt @@ -60,15 +60,23 @@ private fun Flashbar.Builder.applyIcon(iconType: IconType): Flashbar.Builder = IconType.INFO -> this.infoIcon() } -private fun Activity.showFlashBar( +/** + * Builds and configures a Flashbar for [msg]/[iconType] (icon, and - for an indefinite error - the + * dismiss button), without showing it yet. Shared by [showFlashBar] and [showFlashBarAwaitShown] + * so their setup can't silently diverge. Returns `null` for a `null` [msg] (nothing to show). + */ +private fun Activity.configureFlashbar( msg: Any?, iconType: IconType, - gravity: Flashbar.Gravity = TOP, - duration: Long = Flashbar.DURATION_SHORT, -) { - val builder = - flashbarBuilder(gravity, duration) - .applyIcon(iconType) + gravity: Flashbar.Gravity, + duration: Long, +): Flashbar.Builder? { + if (msg == null) return null + if (msg !is Int && msg !is String) { + throw IllegalArgumentException("Message must be String or Int resource") + } + + val builder = flashbarBuilder(gravity, duration).applyIcon(iconType) // Add a close button if the flashbar is an indefinite error if (duration == DURATION_INDEFINITE && iconType == IconType.ERROR) { @@ -77,26 +85,20 @@ private fun Activity.showFlashBar( } when (msg) { - null -> { - return - } - - is Int -> { - builder - .message(msg) - .showOnUiThread() - } + is Int -> builder.message(msg) + is String -> builder.message(msg) + } - is String -> { - builder - .message(msg) - .showOnUiThread() - } + return builder +} - else -> { - throw IllegalArgumentException("Message must be String or Int resource") - } - } +private fun Activity.showFlashBar( + msg: Any?, + iconType: IconType, + gravity: Flashbar.Gravity = TOP, + duration: Long = Flashbar.DURATION_SHORT, +) { + configureFlashbar(msg, iconType, gravity, duration)?.showOnUiThread() } @JvmOverloads @@ -154,19 +156,7 @@ private suspend fun Activity.showFlashBarAwaitShown( gravity: Flashbar.Gravity = TOP, duration: Long = Flashbar.DURATION_SHORT, ) { - val builder = flashbarBuilder(gravity, duration).applyIcon(iconType) - - if (duration == DURATION_INDEFINITE && iconType == IconType.ERROR) { - builder.positiveActionText(getString(R.string.dismiss)) - builder.positiveActionTapListener { it.dismiss() } - } - - when (msg) { - null -> return - is Int -> builder.message(msg) - is String -> builder.message(msg) - else -> throw IllegalArgumentException("Message must be String or Int resource") - } + val builder = configureFlashbar(msg, iconType, gravity, duration) ?: return val shown = CompletableDeferred() builder.barShowListener( From 65c70037f3f46eadc95fcbc0b701732c57be0e1b Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 07:09:33 -0700 Subject: [PATCH 14/23] ADFA-4934: Fix findings from second high-effort code review of PR #1682 - CodeEditorView, FileTreeActionHandler: this PR's own new "cgt" archive type was missing from two pre-existing extension allowlists. Opening a .cgt from the file tree would edit its raw zip bytes as text (silent corruption on save) and get blocked by the 10MB file-size guard that every other archive type is exempt from. - ExternalFileInstallActivity: singleTask + onNewIntent, so a rapid double-tap on the same external file collapses into one Activity/ViewModel instance (whose receivedUriGate already dedupes by Uri) instead of spinning up a second instance that mints an independent temp file PluginManagerViewModel's path-based dedup can't recognize as the same source. Verified on-device: a duplicate launch now hits the same instance and shows one dialog, not two. - PluginManagerActivity: check the forwarded temp file still exists before showing the install-confirmation dialog, so a file removed by InstallTempFiles' stale-file sweep surfaces a clear message instead of a generic install failure. Also merged the forced/normal dialog branches into one builder so a future button/copy change can't be applied to only one and reintroduce a leaked-temp-file bug. - PluginManagerViewModel: clean up a forwarded LocalFile temp copy on cancellation too (previously only success/failure paths did), and stopped deleteSourceDocument() from swallowing CancellationException. Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 1 + .../activities/ExternalFileInstallActivity.kt | 23 +++++++-- .../activities/PluginManagerActivity.kt | 51 ++++++++++--------- .../handlers/FileTreeActionHandler.kt | 3 +- .../itsaky/androidide/ui/CodeEditorView.kt | 3 +- .../viewmodels/PluginManagerViewModel.kt | 7 +++ 6 files changed, 58 insertions(+), 30 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index be8009cd0a..23187ef5b7 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -174,6 +174,7 @@ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|locale|fontScale|density|keyboard|keyboardHidden|navigation" android:excludeFromRecents="true" android:exported="true" + android:launchMode="singleTask" android:theme="@style/Theme.AndroidIDE"> diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt index e90e319e7a..9ff9b806cc 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt @@ -1,5 +1,6 @@ package com.itsaky.androidide.activities +import android.content.Intent import android.os.Bundle import android.view.View import androidx.compose.ui.platform.ComposeView @@ -11,6 +12,12 @@ import org.koin.androidx.viewmodel.ext.android.viewModel * Trampoline activity that receives a `.cgp`/`.cgt` file opened from outside the app (e.g. an * email attachment), prompts to install it, and finishes - it has no content of its own beyond * the dialogs [ExternalFileInstallScreen] shows. + * + * `singleTask` (manifest) + [onNewIntent] collapse a rapid double-tap on the same external file + * into this one instance/ViewModel, where [ExternalFileInstallViewModel]'s `receivedUriGate` + * already dedupes by Uri - without it, `standard` launch mode would spin up a second + * Activity+ViewModel pair minting an independent temp file, which `PluginManagerViewModel`'s + * path-based dedup guard can't recognize as the same source. */ class ExternalFileInstallActivity : IDEActivity() { private val viewModel: ExternalFileInstallViewModel by viewModel() @@ -22,7 +29,16 @@ class ExternalFileInstallActivity : IDEActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + handleIntent() + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent() + } + private fun handleIntent() { val uri = intent?.data if (uri == null) { finish() @@ -30,9 +46,10 @@ class ExternalFileInstallActivity : IDEActivity() { } // No savedInstanceState guard here: onReceived() is idempotent per ViewModel instance - // (a rotation keeps the same instance, so this is a no-op there), and calling it - // unconditionally means a process-death-recreated instance - which starts fresh and - // would otherwise never see the restored intent's data - still gets processed. + // (a rotation, or a re-delivered intent via onNewIntent, keeps the same instance, so this + // is a no-op there), and calling it unconditionally means a process-death-recreated + // instance - which starts fresh and would otherwise never see the restored intent's data - + // still gets processed. viewModel.onReceived(uri) } } diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index 3e32c1e5f5..2566c2f97e 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -123,7 +123,15 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { // install. The intent's extra itself is preserved across both cases by the OS. intent.getStringExtra(EXTRA_PENDING_INSTALL_FILE_PATH)?.let { filePath -> if (viewModel.markPendingInstallHandled(filePath)) { - showInstallConfirmation(PluginInstallSource.LocalFile(File(filePath)), forceDeleteSource = true) + val file = File(filePath) + if (file.exists()) { + showInstallConfirmation(PluginInstallSource.LocalFile(file), forceDeleteSource = true) + } else { + // Can legitimately happen if InstallTempFiles' stale-file sweep (or an + // earlier failed cleanup) removed the temp file before this dialog ever + // got a chance to show it - a clear message beats a generic install error. + flashError(getString(R.string.msg_plugin_file_not_found)) + } } } } catch (e: Exception) { @@ -324,40 +332,33 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { private fun Uri.isSupportedPluginFile(): Boolean = getFileName(this@PluginManagerActivity).endsWith(PLUGIN_EXTENSION, ignoreCase = true) /** - * @param forceDeleteSource When true (a `.cgp` forwarded from - * [ExternalFileInstallActivity]), [source] is our own hidden temp copy, not a file the user - * picked - there's no source worth keeping, so the "delete source" checkbox is skipped - * entirely and the temp file is always cleaned up. + * @param forceDeleteSource When true (a `.cgp` forwarded from [ExternalFileInstallActivity]), + * [source] is our own hidden temp copy, not a file the user picked - there's no checkbox to + * offer (deletion isn't optional) and no source worth keeping on decline/cancel either, so + * both the negative button and back-press/tap-outside route to [PluginManagerUiEvent.CancelPendingInstall]. + * One shared dialog builder for both cases so a future button/copy change can't be applied to + * only one branch and silently reintroduce a leaked-temp-file bug in the other. */ private fun showInstallConfirmation( source: PluginInstallSource, forceDeleteSource: Boolean = false, ) { - if (forceDeleteSource) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_install_plugin) - .setPositiveButton(R.string.btn_install) { _, _ -> - viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(source, deleteSourceAfterInstall = true)) - }.setNegativeButton(android.R.string.cancel) { _, _ -> - viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source, deleteSourceAfterInstall = true)) - }.setOnCancelListener { - // Dialogs default to cancelable=true: back-press/tap-outside must be treated - // the same as the negative button, or the forwarded temp file behind [source] - // is leaked forever with no other cleanup path. - viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source, deleteSourceAfterInstall = true)) - }.show() - return + val dialogView = if (forceDeleteSource) null else layoutInflater.inflate(R.layout.dialog_install_plugin, null) + val deleteCheckBox = dialogView?.findViewById(R.id.checkbox_delete_source) + val onCancel = { + if (forceDeleteSource) { + viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source, deleteSourceAfterInstall = true)) + } } - val dialogView = layoutInflater.inflate(R.layout.dialog_install_plugin, null) - val deleteCheckBox = dialogView.findViewById(R.id.checkbox_delete_source) - MaterialAlertDialogBuilder(this) .setTitle(R.string.title_install_plugin) - .setView(dialogView) + .apply { dialogView?.let { setView(it) } } .setPositiveButton(R.string.btn_install) { _, _ -> - viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(source, deleteCheckBox.isChecked)) - }.setNegativeButton(android.R.string.cancel, null) + val deleteSourceAfterInstall = if (forceDeleteSource) true else deleteCheckBox?.isChecked == true + viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(source, deleteSourceAfterInstall)) + }.setNegativeButton(android.R.string.cancel) { _, _ -> onCancel() } + .setOnCancelListener { onCancel() } .show() } diff --git a/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt b/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt index 8e25862d9f..690aa71373 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt @@ -41,6 +41,7 @@ import com.itsaky.androidide.utils.flashError import com.unnamed.b.atv.model.TreeNode import kotlinx.coroutines.launch import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode.MAIN @@ -74,7 +75,7 @@ class FileTreeActionHandler : BaseEventHandler() { val context = event[Context::class.java]!! as EditorHandlerActivity context.binding.editorDrawerLayout.closeDrawer(GravityCompat.START) - val isArchive = event.file.extension.lowercase() in setOf("apk", PLUGIN_ARCHIVE_EXTENSION, "zip") + val isArchive = event.file.extension.lowercase() in setOf("apk", PLUGIN_ARCHIVE_EXTENSION, TEMPLATE_ARCHIVE_EXTENSION, "zip") if (!isArchive && MB_10 < event.file.length()) { flashError("File is too big!") log.warn("Cannot open {} as it is too big. File size: {} bytes", event.file, event.file.length()) diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt index 56a886e4eb..4324eec039 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -77,6 +77,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.withContext import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION +import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -89,7 +90,7 @@ import kotlin.math.abs private const val MIN_FONT_SIZE = EditorPreferences.FONT_SIZE_MIN private const val DEFAULT_FONT_SIZE = EditorPreferences.FONT_SIZE_DEFAULT private const val MAX_FONT_SIZE = EditorPreferences.FONT_SIZE_MAX -private val ARCHIVE_EXTENSIONS = setOf("apk", PLUGIN_ARCHIVE_EXTENSION, "zip") +private val ARCHIVE_EXTENSIONS = setOf("apk", PLUGIN_ARCHIVE_EXTENSION, TEMPLATE_ARCHIVE_EXTENSION, "zip") /** * A view that handles opened code editor. diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index 66d03824ad..a881c25c6d 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -385,6 +385,11 @@ class PluginManagerViewModel( } } } catch (e: CancellationException) { + // Matches the "always cleaned up regardless of outcome" comment above: cancellation + // is itself an outcome the forwarded temp file must not survive. + if (source is PluginInstallSource.LocalFile) { + withContext(NonCancellable) { deleteInstallSource(source) } + } throw e } catch (exception: Exception) { Log.e(TAG, "Error installing plugin from URI", exception) @@ -482,6 +487,8 @@ class PluginManagerViewModel( PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), ) } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Log.w(TAG, "Failed to delete source document", e) _uiEffect.trySend( From 007ce66bf5325ba431e3750cc4c6252a3487d207 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 08:52:39 -0700 Subject: [PATCH 15/23] ADFA-4934: Fix findings from third high-effort code review of PR #1682 - ExternalFileInstallViewModel: fix a regression the singleTask change introduced - a rapid second VIEW intent for a *different* file could have its confirm-dialog effect overwritten by a slower first request that happened to finish its async work later, since the two onReceived() calls run as independent coroutines with no ordering guarantee. Added a generation counter, assigned synchronously so it always reflects real intent-arrival order; a request whose generation is no longer current abandons itself (and its temp file) instead of emitting a stale effect. Verified on-device: the previous ("clean up at start") approach left both files' temp copies on disk; this one leaves exactly one, matching whichever file's dialog is showing. - PluginManagerViewModel: fixed an ownedTempFile assignment race (a cancellation landing exactly as the ContentUri copy finished could skip the `.also{}` that recorded it, leaking the copy past `finally`'s cleanup) by assigning it as a plain statement before the copy runs, not after the whole block returns. - PluginManagerViewModel/PluginManagerActivity/PluginManagerUiState: extracted a deleteIfLocalFile() helper, replacing 6 copies of the same `if (source is PluginInstallSource.LocalFile) deleteInstallSource(source)` guard, and removed CancelPendingInstall's now-dead deleteSourceAfterInstall field (the handler stopped reading it once an earlier fix switched to checking the source type directly). Co-Authored-By: Claude Sonnet 5 --- .../activities/PluginManagerActivity.kt | 10 +-- .../ui/models/PluginManagerUiState.kt | 1 - .../ExternalFileInstallViewModel.kt | 72 ++++++++++++++++--- .../viewmodels/PluginManagerViewModel.kt | 45 +++++++----- .../ExternalFileInstallViewModelTest.kt | 24 +++++++ 5 files changed, 117 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index 2566c2f97e..22b3a93dfd 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -347,7 +347,7 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { val deleteCheckBox = dialogView?.findViewById(R.id.checkbox_delete_source) val onCancel = { if (forceDeleteSource) { - viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source, deleteSourceAfterInstall = true)) + viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source)) } } @@ -377,15 +377,11 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { PluginManagerUiEvent.ConfirmOverwrite(effect.source, effect.deleteSourceAfterInstall), ) }.setNegativeButton(android.R.string.cancel) { _, _ -> - viewModel.onEvent( - PluginManagerUiEvent.CancelPendingInstall(effect.source, effect.deleteSourceAfterInstall), - ) + viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(effect.source)) }.setOnCancelListener { // Same reasoning as showInstallConfirmation()'s onCancelListener: back-press must // route through CancelPendingInstall too, or a forwarded source's temp file leaks. - viewModel.onEvent( - PluginManagerUiEvent.CancelPendingInstall(effect.source, effect.deleteSourceAfterInstall), - ) + viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(effect.source)) }.show() } diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt index 54e931b91d..07c4642fbb 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt @@ -62,7 +62,6 @@ sealed class PluginManagerUiEvent { data class CancelPendingInstall( val source: PluginInstallSource, - val deleteSourceAfterInstall: Boolean, ) : PluginManagerUiEvent() object OpenFilePicker : PluginManagerUiEvent() diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index 5fa6f91606..4799b0d0c9 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -75,35 +75,62 @@ class ExternalFileInstallViewModel( private val _isInstalling = MutableStateFlow(false) val isInstalling: StateFlow = _isInstalling.asStateFlow() - /** Call once, from `Activity.onCreate()`, with the VIEW intent's data [Uri]. */ + // Monotonically increasing per onReceived() call, assigned synchronously (before launching + // the coroutine below) so it always reflects real intent-arrival order. Two onReceived() + // calls in quick succession (ExternalFileInstallActivity is singleTask, so a second VIEW + // intent for a *different* file reaches this same instance via onNewIntent) run as + // independent coroutines with no guarantee the first *finishes* before the second - a slow + // first request can otherwise complete its async work (copy/inspect/collision-check) after a + // faster second request already committed, and overwrite the Compose screen's single + // dialogState slot with stale info. isCurrentGeneration() below lets each request notice, at + // its final commit point, that it's been superseded and should abandon silently instead. + private var currentRequestGeneration = 0 + + // Tracks the temp file behind the most recently *committed* .cgt confirm/conflict dialog - + // used to clean it up the moment a newer request supersedes it, rather than silently + // orphaning it for InstallTempFiles' hour-long sweep. Only ever touched by whichever request + // currently holds isCurrentGeneration()'s "true" (see supersedePendingConfirmation()), so + // there's no ordering ambiguity about which file it refers to. + private var pendingConfirmationTempFile: File? = null + + private fun isCurrentGeneration(generation: Int) = generation == currentRequestGeneration + + private suspend fun supersedePendingConfirmation(newPendingFile: File?) { + pendingConfirmationTempFile?.let { old -> if (old != newPendingFile) deleteQuietly(old) } + pendingConfirmationTempFile = newPendingFile + } + + /** Call once, from `Activity.onCreate()`/`onNewIntent()`, with the VIEW intent's data [Uri]. */ fun onReceived(uri: Uri) { if (!receivedUriGate.consume(uri)) return + val generation = ++currentRequestGeneration + viewModelScope.launch { val displayName = withContext(Dispatchers.IO) { UriFileImporter.getDisplayName(contentResolver, uri) } val extension = displayName?.substringAfterLast('.', "")?.lowercase() if (displayName.isNullOrBlank() || extension.isNullOrBlank()) { - sendErrorAndFinish(R.string.msg_invalid_incoming_file) + sendErrorAndFinish(generation, R.string.msg_invalid_incoming_file) return@launch } if (extension != PLUGIN_ARCHIVE_EXTENSION && extension != TEMPLATE_ARCHIVE_EXTENSION) { - sendErrorAndFinish(R.string.msg_unsupported_file_type) + sendErrorAndFinish(generation, R.string.msg_unsupported_file_type) return@launch } if (extension == PLUGIN_ARCHIVE_EXTENSION && !awaitAvailable(pluginRepository::isPluginManagerAvailable) ) { - sendErrorAndFinish(R.string.msg_ide_setup_incomplete) + sendErrorAndFinish(generation, R.string.msg_ide_setup_incomplete) return@launch } if (extension == TEMPLATE_ARCHIVE_EXTENSION && !awaitAvailable(templateCollectionRepository::isTemplatesFeatureAvailable) ) { - sendErrorAndFinish(R.string.msg_ide_setup_incomplete) + sendErrorAndFinish(generation, R.string.msg_ide_setup_incomplete) return@launch } @@ -123,19 +150,27 @@ class ExternalFileInstallViewModel( } catch (e: Exception) { log.error("Failed to copy incoming file", e) withContext(Dispatchers.IO) { deleteQuietlyBlocking(destination) } - sendErrorAndFinish(R.string.msg_invalid_incoming_file) + sendErrorAndFinish(generation, R.string.msg_invalid_incoming_file) return@launch } + if (!isCurrentGeneration(generation)) { + // A newer VIEW intent has since arrived and is now authoritative - abandon this + // one silently rather than emit an effect that would incorrectly supersede it. + deleteQuietly(tempFile) + return@launch + } + val baseName = sanitizeBaseName(displayName.substringBeforeLast('.', "templates")) if (extension == PLUGIN_ARCHIVE_EXTENSION) { // Forwarded as a plain path, not a content:// Uri: both activities run in this // same process and already trust filesDir paths, so PluginManagerViewModel can // install straight from this file instead of copying it a second time. + supersedePendingConfirmation(null) _uiEffect.trySend(ExternalFileInstallUiEffect.ForwardToPluginManager(tempFile.absolutePath)) } else { - dispatchTemplateInstall(tempFile, baseName) + dispatchTemplateInstall(tempFile, baseName, generation) } } } @@ -151,16 +186,24 @@ class ExternalFileInstallViewModel( private suspend fun dispatchTemplateInstall( tempFile: File, baseName: String, + generation: Int, ) { val info = templateCollectionRepository.inspectCollection(tempFile).getOrElse { exception -> log.warn("Invalid template collection file: {}", tempFile.name, exception) deleteQuietly(tempFile) - sendErrorAndFinish(R.string.msg_template_invalid_file) + sendErrorAndFinish(generation, R.string.msg_template_invalid_file) return } val existing = templateCollectionRepository.findExistingCollision(baseName) + + if (!isCurrentGeneration(generation)) { + deleteQuietly(tempFile) + return + } + + supersedePendingConfirmation(tempFile) if (existing == null) { _uiEffect.trySend( ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation(info, tempFile, baseName), @@ -179,6 +222,7 @@ class ExternalFileInstallViewModel( } is ExternalFileInstallUiEvent.IgnoreTemplateInstall -> { + if (pendingConfirmationTempFile == event.tempFile) pendingConfirmationTempFile = null viewModelScope.launch { deleteQuietly(event.tempFile) _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) @@ -197,6 +241,10 @@ class ExternalFileInstallViewModel( // tempFile and surface a spurious failure toast. if (_isInstalling.value) return _isInstalling.value = true + // From here on, tempFile's fate is owned by this install attempt, not "a dialog awaiting + // an answer" - a subsequent onReceived() for a different file must not delete it out from + // under an install already in flight. + if (pendingConfirmationTempFile == tempFile) pendingConfirmationTempFile = null viewModelScope.launch { templateCollectionRepository @@ -231,9 +279,15 @@ class ExternalFileInstallViewModel( fun sanitizeBaseName(rawName: String): String = rawName.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { "templates" } - private fun sendErrorAndFinish( + private suspend fun sendErrorAndFinish( + generation: Int, @StringRes messageResId: Int, ) { + if (!isCurrentGeneration(generation)) return + // A stale (already-superseded) request never reaches here (see the isCurrentGeneration + // check above), so whatever's still pending at this point genuinely belongs to an earlier, + // now-being-terminated request and must be cleaned up rather than left dangling. + supersedePendingConfirmation(null) // See confirmTemplateInstall()'s onSuccess: the Screen suspends on ShowError until the // flashbar is actually shown before processing Finish, so no delay is needed here either. _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(messageResId)) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index a881c25c6d..ad768ebf31 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -126,12 +126,10 @@ class PluginManagerViewModel( is PluginManagerUiEvent.CancelPendingInstall -> { // Only a forwarded LocalFile (our own disposable temp copy) is cleaned up here - // nothing was installed, so a user-picked ContentUri source is never touched on - // decline, regardless of deleteSourceAfterInstall (that flag only ever governs - // deletion after a *successful* install, matching its "delete after install" - // label). - if (event.source is PluginInstallSource.LocalFile) { - viewModelScope.launch { deleteInstallSource(event.source) } - } + // decline (deletion there only ever happens after a *successful* install, + // matching the "delete after install" checkbox's label - there's no flag to + // consult here since a decline never installs anything). + viewModelScope.launch { deleteIfLocalFile(event.source) } } is PluginManagerUiEvent.OpenFilePicker -> { @@ -345,12 +343,20 @@ class PluginManagerViewModel( "apk" } val tempFile = InstallTempFiles.newTempFile(filesDir, "temp_plugin", extension) + // Assigned immediately (a plain, non-suspending write), before the + // suspending copy below - so a cancellation landing mid-copy still + // leaves ownedTempFile pointing at the file for `finally` to clean + // up. Assigning only after this whole block returns (e.g. via + // `.also{}` on the block's result) would miss that window: a + // cancellation right as the block finishes makes withContext throw + // instead of returning, so the assignment would never run. + ownedTempFile = tempFile UriFileImporter.copyUriToFile(contentResolver, source.uri, tempFile) { Exception("Cannot open file") } tempFile - }.also { ownedTempFile = it } + } } } @@ -380,16 +386,12 @@ class PluginManagerViewModel( // A failed install deletes nothing but our own disposable temp copy - a // user-picked ContentUri is preserved so they can retry, matching // deleteSourceAfterInstall's "delete after install [succeeds]" meaning. - if (source is PluginInstallSource.LocalFile) { - deleteInstallSource(source) - } + deleteIfLocalFile(source) } } catch (e: CancellationException) { // Matches the "always cleaned up regardless of outcome" comment above: cancellation // is itself an outcome the forwarded temp file must not survive. - if (source is PluginInstallSource.LocalFile) { - withContext(NonCancellable) { deleteInstallSource(source) } - } + withContext(NonCancellable) { deleteIfLocalFile(source) } throw e } catch (exception: Exception) { Log.e(TAG, "Error installing plugin from URI", exception) @@ -399,9 +401,7 @@ class PluginManagerViewModel( listOf(exception.message ?: ""), ), ) - if (source is PluginInstallSource.LocalFile) { - deleteInstallSource(source) - } + deleteIfLocalFile(source) } finally { ownedTempFile?.let { file -> withContext(NonCancellable + Dispatchers.IO) { @@ -425,7 +425,7 @@ class PluginManagerViewModel( if (incoming == null) { Log.w(TAG, "Failed to read plugin metadata from ${pluginFile.name}; aborting install") _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file)) - if (source is PluginInstallSource.LocalFile) deleteInstallSource(source) + deleteIfLocalFile(source) return true } @@ -445,7 +445,7 @@ class PluginManagerViewModel( listOf(existing.metadata.name), ), ) - if (source is PluginInstallSource.LocalFile) deleteInstallSource(source) + deleteIfLocalFile(source) return true } @@ -463,6 +463,15 @@ class PluginManagerViewModel( return true } + /** A user-picked ContentUri is only ever deleted after a successful install (matching the + * "delete after install" checkbox's label) - a forwarded LocalFile temp copy is disposable + * regardless of outcome, so it's the only source type any non-success path cleans up here. */ + private suspend fun deleteIfLocalFile(source: PluginInstallSource) { + if (source is PluginInstallSource.LocalFile) { + deleteInstallSource(source) + } + } + private suspend fun deleteInstallSource(source: PluginInstallSource) { when (source) { is PluginInstallSource.LocalFile -> { diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt index 663285eaa5..9ed539b6ac 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt @@ -136,6 +136,30 @@ class ExternalFileInstallViewModelTest { assertThat((first as ExternalFileInstallUiEffect.ShowTemplateNameConflict).existingName).isEqualTo("my-templates") } + @Test + fun `a second onReceived for a different file cleans up the first file's still-pending temp copy`() = + runTest { + // Simulates a second VIEW intent for a different file arriving via onNewIntent() on + // the singleTask ExternalFileInstallActivity while the first file's confirmation + // dialog is still unanswered. + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + + viewModel.onReceived(sourceUriFor("first.cgt")) + val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + val firstTempFile = firstEffect.tempFile + assertThat(firstTempFile.exists()).isTrue() + + viewModel.onReceived(sourceUriFor("second.cgt")) + val secondEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + + assertThat(firstTempFile.exists()).isFalse() + assertThat(secondEffect.tempFile).isNotEqualTo(firstTempFile) + assertThat(secondEffect.tempFile.exists()).isTrue() + } + @Test fun `invalid cgt shows invalid-file error`() = runTest { From 08202f89fe4d8f6ad8da699369b79a8edc75fa62 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 09:20:53 -0700 Subject: [PATCH 16/23] ADFA-4934: Fix findings from fourth high-effort code review of PR #1682 - TemplateCollectionRepositoryImpl: the backup step (moving an existing destFile aside before the swap) had no copy+delete fallback, unlike the swap and restore steps a few lines below - meaning an overwrite install could always fail on a device where renameTo() is unreliable even for a same-directory move (the exact issue this PR already fixed for the swap/restore steps). Applied the same fallback here too. - ExternalFileInstallViewModel: extend the generation-gating from the previous fix to installation completion, not just dialog dispatch - confirmTemplateInstall() now captures its generation and checks it before sending Finish (so a slow install for an abandoned dialog can't tear down the Activity out from under a newer, unrelated dialog) and before touching `_isInstalling` (so a stale install completing can't re-lock a newer dialog's buttons). dispatchTemplateInstall() now also resets `_isInstalling` when committing to show a new dialog, so it isn't left stuck "true" by an abandoned generation's still-running install. - InstallTempFiles: throttle sweepStale() to once per 10 minutes instead of a full directory scan on every single temp-file creation - stale entries can only appear once per hour (MAX_AGE_MS) regardless. Verification note: the physical test device was unreachable this round (disconnected mid-session) - verified via the full relevant unit test suite (including a new test locking in the isInstalling-scoping fix) and careful tracing of the generation-check logic, which builds directly on the already on-device-verified mechanism from the previous commit. Co-Authored-By: Claude Sonnet 5 --- .../TemplateCollectionRepositoryImpl.kt | 15 ++++-- .../androidide/utils/InstallTempFiles.kt | 16 ++++-- .../ExternalFileInstallViewModel.kt | 50 +++++++++++++++---- .../ExternalFileInstallViewModelTest.kt | 37 ++++++++++++++ 4 files changed, 101 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt index b528e71947..dced182e21 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -140,12 +140,19 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { // Back up (rather than delete) any existing destFile, so it can be put back if // the swap below fails for any reason - the existing collection is only ever - // removed once the new one is confirmed successfully in its place. + // removed once the new one is confirmed successfully in its place. Same + // renameTo()-then-copyTo() fallback as the swap below: renameTo() is unreliable + // on-device even for a same-directory move (confirmed there during this PR). val hadExisting = destFile.exists() val backupFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.bak") - if (hadExisting && !destFile.renameTo(backupFile)) { - stagingFile.delete() - throw IllegalStateException("Failed to back up existing file before replacing: ${destFile.name}") + if (hadExisting) { + val backedUp = + destFile.renameTo(backupFile) || + runCatching { destFile.copyTo(backupFile, overwrite = true).also { destFile.delete() } }.isSuccess + if (!backedUp) { + stagingFile.delete() + throw IllegalStateException("Failed to back up existing file before replacing: ${destFile.name}") + } } // Both files are now on the same volume (templatesDir), so this is a cheap, diff --git a/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt b/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt index 70bed2c5e7..bf1cb4ce87 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt @@ -14,6 +14,12 @@ import java.util.concurrent.TimeUnit object InstallTempFiles { private val MAX_AGE_MS = TimeUnit.HOURS.toMillis(1) + // Stale entries can only ever appear once an hour (MAX_AGE_MS), so there's no point + // re-scanning the directory on every single newTempFile() call - throttle to once per + // interval instead of doing a full listFiles()+lastModified() pass every time. + private val SWEEP_INTERVAL_MS = TimeUnit.MINUTES.toMillis(10) + private var lastSweepAtMs = 0L + /** Creates a uniquely-named `_.` file under `filesDir/temp`. */ fun newTempFile( filesDir: File, @@ -21,12 +27,16 @@ object InstallTempFiles { extension: String, ): File { val tempDir = File(filesDir, "temp").apply { mkdirs() } - sweepStale(tempDir) + sweepStaleIfDue(tempDir) return File(tempDir, "${prefix}_${UUID.randomUUID()}.$extension") } - private fun sweepStale(tempDir: File) { - val cutoff = System.currentTimeMillis() - MAX_AGE_MS + private fun sweepStaleIfDue(tempDir: File) { + val now = System.currentTimeMillis() + if (now - lastSweepAtMs < SWEEP_INTERVAL_MS) return + lastSweepAtMs = now + + val cutoff = now - MAX_AGE_MS tempDir.listFiles()?.forEach { file -> if (file.lastModified() < cutoff) { file.delete() diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index 4799b0d0c9..3512a5dcb7 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -204,6 +204,10 @@ class ExternalFileInstallViewModel( } supersedePendingConfirmation(tempFile) + // This dialog's buttons must start enabled regardless of whether some earlier, + // now-abandoned generation's install is still finishing up in the background (see + // confirmTemplateInstall()'s own generation check for the other half of this). + _isInstalling.value = false if (existing == null) { _uiEffect.trySend( ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation(info, tempFile, baseName), @@ -241,6 +245,11 @@ class ExternalFileInstallViewModel( // tempFile and surface a spurious failure toast. if (_isInstalling.value) return _isInstalling.value = true + // Captured now: if a newer onReceived() supersedes this one's dialog before this install + // finishes (see dispatchTemplateInstall()), this install must neither tear down the + // Activity out from under the newer dialog nor touch _isInstalling/pendingConfirmation + // state that by then belongs to a completely different request. + val generation = currentRequestGeneration // From here on, tempFile's fate is owned by this install attempt, not "a dialog awaiting // an answer" - a subsequent onReceived() for a different file must not delete it out from // under an install already in flight. @@ -250,23 +259,44 @@ class ExternalFileInstallViewModel( templateCollectionRepository .installCollection(tempFile, targetBaseName, overwrite) .onSuccess { - // The Screen suspends on ShowSuccess until the flashbar's entrance animation - // actually finishes (flashSuccessAwaitShown) before processing the next - // buffered effect, so Finish here doesn't need its own artificial delay. + // The install genuinely happened (the file's on disk in templatesDir) even if + // a newer request has since taken over the screen, so still surface the + // success - but only tear down the Activity (Finish) if nothing newer is now + // relying on it staying alive. _uiEffect.trySend(ExternalFileInstallUiEffect.ShowSuccess(R.string.msg_template_installed)) - _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) + if (isCurrentGeneration(generation)) { + // The Screen suspends on ShowSuccess until the flashbar's entrance + // animation actually finishes (flashSuccessAwaitShown) before processing + // the next buffered effect, so Finish here doesn't need its own delay. + _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) + } }.onFailure { exception -> - // Deliberately don't delete tempFile or Finish here: the dialog the user was - // just on (install-confirm / name-conflict / rename) stays open so they can - // retry - e.g. pick a different name after a collision, or Overwrite instead. log.error("Failed to install template collection", exception) - _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(R.string.msg_template_install_failed)) + if (isCurrentGeneration(generation)) { + // Deliberately don't delete tempFile or Finish here: the dialog the user + // was just on (install-confirm / name-conflict / rename) stays open so + // they can retry - e.g. pick a different name after a collision, or + // Overwrite instead. If a newer request has since superseded this dialog, + // there's nothing left on-screen to retry against, so skip ShowError too. + _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(R.string.msg_template_install_failed)) + } } - _isInstalling.value = false + if (isCurrentGeneration(generation)) { + _isInstalling.value = false + } } } - /** Suggests a unique base name for the rename dialog by appending "(2)", "(3)", etc. */ + /** + * Suggests a unique base name for the rename dialog by appending "(2)", "(3)", etc. + * + * Each candidate is checked via [TemplateCollectionRepository.findExistingCollision] - a + * fresh directory listing per call - rather than listing `templatesDir` once and checking + * membership in-memory. Left as-is deliberately: [MAX_SUGGESTION_ATTEMPTS] already bounds + * the worst case, a real templates directory is realistically small (a user's own installed + * collections), and avoiding the redundant scans would mean adding a batch-listing method to + * [TemplateCollectionRepository] purely for this one call site's benefit. + */ suspend fun suggestUniqueBaseName(baseName: String): String { var candidate = baseName var suffix = 2 diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt index 9ed539b6ac..8b75ec2ab7 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt @@ -8,11 +8,13 @@ import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.repositories.PluginRepository import com.itsaky.androidide.repositories.TemplateCollectionRepository import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect +import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent import com.itsaky.androidide.viewmodel.MainDispatcherRule import io.mockk.coEvery import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -160,6 +162,41 @@ class ExternalFileInstallViewModelTest { assertThat(secondEffect.tempFile.exists()).isTrue() } + @Test + fun `isInstalling for a superseded generation does not block a newer dialog's buttons`() = + runTest { + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + + val installDeferred = CompletableDeferred>() + coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } coAnswers { installDeferred.await() } + + viewModel.onReceived(sourceUriFor("first.cgt")) + val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall(firstEffect.tempFile, firstEffect.suggestedBaseName, overwrite = false), + ) + assertThat(viewModel.isInstalling.value).isTrue() + + // A second, unrelated file arrives (e.g. via onNewIntent on the singleTask activity) + // while the first file's install is still in flight. + viewModel.onReceived(sourceUriFor("second.cgt")) + viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + + // The new dialog must not render with its buttons disabled just because an unrelated, + // already-superseded install is still finishing up in the background. + assertThat(viewModel.isInstalling.value).isFalse() + + installDeferred.complete(Result.success(Unit)) + viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowSuccess + + // The now-completed, superseded install must not re-enable (or otherwise touch) + // isInstalling on behalf of the current, unrelated generation. + assertThat(viewModel.isInstalling.value).isFalse() + } + @Test fun `invalid cgt shows invalid-file error`() = runTest { From 90c30b9437f55b5ec2f721afabbee5fcc9559e47 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 11:39:29 -0700 Subject: [PATCH 17/23] ADFA-4934: Fix findings from fifth high-effort code review of PR #1682 - PluginManagerActivity: a .cgp forwarded from ExternalFileInstallActivity could stack a second Plugin Manager instance on top of one the user already had open/backgrounded. ForwardToPluginManager's launch Intent now carries FLAG_ACTIVITY_CLEAR_TOP|FLAG_ACTIVITY_SINGLE_TOP, and PluginManagerActivity gained an onNewIntent() override (mirroring ExternalFileInstallActivity's own singleTask handling) so a reused instance still processes the forwarded install instead of silently dropping it. - PluginManagerActivity: the forwarded-install file.exists() check ran synchronously on the main thread during onCreate()/onNewIntent(); moved onto Dispatchers.IO like the other file-system checks in this flow. - PluginManagerActivity: dropped showInstallConfirmation's redundant forceDeleteSource parameter - it was 100% determined by source's runtime type at both call sites, so compute it internally instead. - ExternalFileInstallViewModel: confirmTemplateInstall's success message now includes the target base name, so the toast is unambiguous even when a slow install completes after a newer, unrelated dialog has already taken over the screen (it must still fire per the existing isInstalling-scoping test - the install genuinely succeeded). - ExternalFileInstallViewModel: collapsed the two structurally-identical plugin/template availability-check blocks into one. - InstallTempFiles: lastSweepAtMs is read/written from coroutines PluginManagerViewModel and ExternalFileInstallViewModel can launch on different dispatchers - switched to AtomicLong with compareAndSet so two near-simultaneous callers can't both pass the throttle check. Not fixed (out of scope / pre-existing, not regressions from this PR): - InstallFileAction not recognizing .cgt for the in-editor "Install" action - a new feature (wiring TemplateCollectionRepository into that action), not a bug in the external-open flow this ticket covers. - ITemplateProvider.getInstance(reload=true) rescanning all installed collections on every install - existing reload API behavior, not something introduced here. - FeedbackManager/FeedbackEmailHandler's duplicated PixelCopy capture logic - pre-existing, unrelated duplication only touched by this PR's formatting pass. Verification: full app unit test suite green; on-device (R5CN80KZCKD) reproduction of the duplicate-instance scenario (two .cgp VIEW intents in quick succession while the first's confirm dialog is still open) confirms a single PluginManagerActivity instance (same ActivityRecord/ task) handles both, no crash. Co-Authored-By: Claude Sonnet 5 --- .../activities/ExternalFileInstallScreen.kt | 7 ++- .../activities/PluginManagerActivity.kt | 56 ++++++++++++------- .../ui/models/ExternalFileInstallUiModels.kt | 5 +- .../androidide/utils/InstallTempFiles.kt | 14 +++-- .../ExternalFileInstallViewModel.kt | 24 ++++---- resources/src/main/res/values/strings.xml | 2 +- 6 files changed, 69 insertions(+), 39 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt index acac8606bb..129bcb9d1c 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt @@ -65,6 +65,11 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { is ExternalFileInstallUiEffect.ForwardToPluginManager -> { context.startActivity( Intent(context, PluginManagerActivity::class.java) + // A Plugin Manager instance may already be running/backgrounded (e.g. + // the user had it open, then opened a .cgp attachment) - these flags + // reuse that instance via onNewIntent() instead of stacking a second + // one on top of it. + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) .putExtra(PluginManagerActivity.EXTRA_PENDING_INSTALL_FILE_PATH, effect.filePath), ) (context as? Activity)?.finish() @@ -91,7 +96,7 @@ fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) { } is ExternalFileInstallUiEffect.ShowSuccess -> { - flashSuccessAwaitShown(context.getString(effect.messageResId)) + flashSuccessAwaitShown(context.getString(effect.messageResId, *effect.formatArgs.toTypedArray())) } is ExternalFileInstallUiEffect.Finish -> { diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt index 22b3a93dfd..08fec8b4da 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -40,7 +40,9 @@ import com.itsaky.androidide.utils.flashbarBuilder import com.itsaky.androidide.utils.getFileName import com.itsaky.androidide.utils.showOnUiThread import com.itsaky.androidide.viewmodels.PluginManagerViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION import org.koin.androidx.viewmodel.ext.android.viewModel import java.io.File @@ -115,17 +117,38 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { setupFeedbackButton() observeViewModel() - // No savedInstanceState guard: markPendingInstallHandled() is the idempotency - // check, scoped to the ViewModel instance rather than the Activity's recreation - // reason - it survives rotation (skips a duplicate dialog there) but resets on - // process death (a fresh ViewModel is created), so a process-death-recreated - // instance still shows the dialog instead of silently dropping the forwarded - // install. The intent's extra itself is preserved across both cases by the OS. - intent.getStringExtra(EXTRA_PENDING_INSTALL_FILE_PATH)?.let { filePath -> - if (viewModel.markPendingInstallHandled(filePath)) { + handlePendingInstallExtra() + } catch (e: Exception) { + // Log the error and finish the activity if something goes wrong + e.printStackTrace() + flashError(getString(R.string.msg_plugin_manager_init_failed, e.message)) + finish() + } + } + + // ForwardToPluginManager's launch Intent carries FLAG_ACTIVITY_CLEAR_TOP/SINGLE_TOP so a + // forwarded install reuses an already-running instance instead of stacking a duplicate one - + // which routes the extra through onNewIntent() rather than a fresh onCreate(). + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handlePendingInstallExtra() + } + + // No savedInstanceState guard: markPendingInstallHandled() is the idempotency check, scoped + // to the ViewModel instance rather than the Activity's recreation reason - it survives + // rotation (skips a duplicate dialog there) but resets on process death (a fresh ViewModel is + // created), so a process-death-recreated instance still shows the dialog instead of silently + // dropping the forwarded install. The intent's extra itself is preserved across both cases by + // the OS. + private fun handlePendingInstallExtra() { + intent.getStringExtra(EXTRA_PENDING_INSTALL_FILE_PATH)?.let { filePath -> + if (viewModel.markPendingInstallHandled(filePath)) { + lifecycleScope.launch { val file = File(filePath) - if (file.exists()) { - showInstallConfirmation(PluginInstallSource.LocalFile(file), forceDeleteSource = true) + val exists = withContext(Dispatchers.IO) { file.exists() } + if (exists) { + showInstallConfirmation(PluginInstallSource.LocalFile(file)) } else { // Can legitimately happen if InstallTempFiles' stale-file sweep (or an // earlier failed cleanup) removed the temp file before this dialog ever @@ -134,11 +157,6 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { } } } - } catch (e: Exception) { - // Log the error and finish the activity if something goes wrong - e.printStackTrace() - flashError(getString(R.string.msg_plugin_manager_init_failed, e.message)) - finish() } } @@ -332,17 +350,15 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { private fun Uri.isSupportedPluginFile(): Boolean = getFileName(this@PluginManagerActivity).endsWith(PLUGIN_EXTENSION, ignoreCase = true) /** - * @param forceDeleteSource When true (a `.cgp` forwarded from [ExternalFileInstallActivity]), + * For a [PluginInstallSource.LocalFile] (a `.cgp` forwarded from [ExternalFileInstallActivity]), * [source] is our own hidden temp copy, not a file the user picked - there's no checkbox to * offer (deletion isn't optional) and no source worth keeping on decline/cancel either, so * both the negative button and back-press/tap-outside route to [PluginManagerUiEvent.CancelPendingInstall]. * One shared dialog builder for both cases so a future button/copy change can't be applied to * only one branch and silently reintroduce a leaked-temp-file bug in the other. */ - private fun showInstallConfirmation( - source: PluginInstallSource, - forceDeleteSource: Boolean = false, - ) { + private fun showInstallConfirmation(source: PluginInstallSource) { + val forceDeleteSource = source is PluginInstallSource.LocalFile val dialogView = if (forceDeleteSource) null else layoutInflater.inflate(R.layout.dialog_install_plugin, null) val deleteCheckBox = dialogView?.findViewById(R.id.checkbox_delete_source) val onCancel = { diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt index 611e0128d4..b41fafda70 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt @@ -40,7 +40,10 @@ sealed class ExternalFileInstallUiEffect { data class ShowSuccess( @StringRes val messageResId: Int, - ) : ExternalFileInstallUiEffect() + val formatArgs: List = emptyList(), + ) : ExternalFileInstallUiEffect() { + constructor(@StringRes messageResId: Int, vararg formatArgs: Any) : this(messageResId, formatArgs.toList()) + } object Finish : ExternalFileInstallUiEffect() } diff --git a/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt b/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt index bf1cb4ce87..421e6d7195 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.utils import java.io.File import java.util.UUID import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong /** * Shared `filesDir/temp` staging area for the .cgp/.cgt install flows (ExternalFileInstallViewModel, @@ -16,9 +17,11 @@ object InstallTempFiles { // Stale entries can only ever appear once an hour (MAX_AGE_MS), so there's no point // re-scanning the directory on every single newTempFile() call - throttle to once per - // interval instead of doing a full listFiles()+lastModified() pass every time. + // interval instead of doing a full listFiles()+lastModified() pass every time. An AtomicLong + // (rather than a plain var) since newTempFile() can be called concurrently from both + // ExternalFileInstallViewModel and PluginManagerViewModel's coroutines. private val SWEEP_INTERVAL_MS = TimeUnit.MINUTES.toMillis(10) - private var lastSweepAtMs = 0L + private val lastSweepAtMs = AtomicLong(0L) /** Creates a uniquely-named `_.` file under `filesDir/temp`. */ fun newTempFile( @@ -33,8 +36,11 @@ object InstallTempFiles { private fun sweepStaleIfDue(tempDir: File) { val now = System.currentTimeMillis() - if (now - lastSweepAtMs < SWEEP_INTERVAL_MS) return - lastSweepAtMs = now + val last = lastSweepAtMs.get() + if (now - last < SWEEP_INTERVAL_MS) return + // Loses the race to another concurrent caller -> that caller's sweep already covers this + // interval, so skip rather than sweep twice. + if (!lastSweepAtMs.compareAndSet(last, now)) return val cutoff = now - MAX_AGE_MS tempDir.listFiles()?.forEach { file -> diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index 3512a5dcb7..377f333221 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -120,16 +120,13 @@ class ExternalFileInstallViewModel( return@launch } - if (extension == PLUGIN_ARCHIVE_EXTENSION && - !awaitAvailable(pluginRepository::isPluginManagerAvailable) - ) { - sendErrorAndFinish(generation, R.string.msg_ide_setup_incomplete) - return@launch - } - - if (extension == TEMPLATE_ARCHIVE_EXTENSION && - !awaitAvailable(templateCollectionRepository::isTemplatesFeatureAvailable) - ) { + val featureAvailable = + if (extension == PLUGIN_ARCHIVE_EXTENSION) { + pluginRepository::isPluginManagerAvailable + } else { + templateCollectionRepository::isTemplatesFeatureAvailable + } + if (!awaitAvailable(featureAvailable)) { sendErrorAndFinish(generation, R.string.msg_ide_setup_incomplete) return@launch } @@ -262,8 +259,11 @@ class ExternalFileInstallViewModel( // The install genuinely happened (the file's on disk in templatesDir) even if // a newer request has since taken over the screen, so still surface the // success - but only tear down the Activity (Finish) if nothing newer is now - // relying on it staying alive. - _uiEffect.trySend(ExternalFileInstallUiEffect.ShowSuccess(R.string.msg_template_installed)) + // relying on it staying alive. targetBaseName is included in the message so + // the toast is unambiguous even when it overlays a newer, unrelated dialog. + _uiEffect.trySend( + ExternalFileInstallUiEffect.ShowSuccess(R.string.msg_template_installed, targetBaseName), + ) if (isCurrentGeneration(generation)) { // The Screen suspends on ShowSuccess until the flashbar's entrance // animation actually finishes (flashSuccessAwaitShown) before processing diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index c1fc5c09c0..0f78996442 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1100,7 +1100,7 @@ Overwrite Rename & Install New collection name - Template collection installed successfully + "%1$s" installed successfully Invalid or corrupted template collection file. Failed to install template collection. From 8bfe68e26e60d2a0e6796a9a8fe9492ae9bc8271 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 11:40:27 -0700 Subject: [PATCH 18/23] ADFA-4934: Fix ktlint line-length wrap in ExternalFileInstallUiModels Co-Authored-By: Claude Sonnet 5 --- .../androidide/ui/models/ExternalFileInstallUiModels.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt index b41fafda70..b319b686a9 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt @@ -42,7 +42,10 @@ sealed class ExternalFileInstallUiEffect { @StringRes val messageResId: Int, val formatArgs: List = emptyList(), ) : ExternalFileInstallUiEffect() { - constructor(@StringRes messageResId: Int, vararg formatArgs: Any) : this(messageResId, formatArgs.toList()) + constructor( + @StringRes messageResId: Int, + vararg formatArgs: Any, + ) : this(messageResId, formatArgs.toList()) } object Finish : ExternalFileInstallUiEffect() From ae77791eecaef37ff2a98feb7c19eabb6f45001f Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 12:32:50 -0700 Subject: [PATCH 19/23] ADFA-4934: Fix findings from sixth high-effort code review of PR #1682 - ExternalFileInstallViewModel: confirmTemplateInstall() captured the live currentRequestGeneration counter instead of the generation the on-screen dialog actually belongs to. A second VIEW intent bumps that counter synchronously before its own dialog is shown, so tapping Install/Overwrite/Rename on the still-visible (but now stale) prior dialog in that window got misattributed to the newer generation - on success this incorrectly sent Finish, tearing the Activity down (and its viewModelScope) out from under the newer, still in-flight request. Fixed by tracking pendingConfirmationGeneration alongside pendingConfirmationTempFile and keying confirmTemplateInstall() off that; also guards against acting on a tempFile that's already been superseded (and deleted) entirely. - ExternalFileInstallViewModel: IgnoreTemplateInstall had the same root cause - a stale Cancel tap unconditionally sent Finish regardless of whether pendingConfirmationTempFile still matched. Now a no-op when it doesn't. - ExternalFileInstallViewModel: suggestUniqueBaseName()'s attempt-bound check ran before the collision check, so the final candidate returned when MAX_SUGGESTION_ATTEMPTS is hit was never actually checked for collision. Reordered the && operands so the bound only short-circuits after that last check has run. - ExternalFileInstallViewModel: template install failures showed a generic, non-actionable message; now includes the underlying reason (reserved name / already exists / swap failure) via a %1$s arg, matching PluginManagerViewModel's equivalent error path. - PluginManagerViewModel: _uiEffect used the default rendezvous channel, the same latent drop-before-collector-attaches bug class this PR already fixed for ExternalFileInstallViewModel's channel. Switched to Channel.BUFFERED for consistency; not user-visible today, but the channel now also serves the forwarded-.cgp path. - TemplateCollectionRepositoryImpl: extracted the renameTo()+copyTo() fallback (triplicated across the backup/swap/restore steps) into a single moveFile() helper. Not fixed (narrow races / low-value at this stage, not regressions): - installCollection() has no per-destination-name locking, so two concurrent installs to the same target name could race on the final swap. Requires two attachments sharing a name AND overlapping generations to hit; accepted as last-write-wins for now. - InstallTempFiles' hour-old sweep could delete a pending confirmation's temp file if the user leaves a dialog open that long; would need cross-ViewModel "file in use" tracking to fix properly. - installPlugin() awaits initialLoadCompleted before copying the incoming URI, when the two could run concurrently - a cold-start-only latency nicety, not a correctness issue. - dispatchTemplateInstall() does its zip-read/collision-check I/O before its own generation check - inherent to check-then-act, the I/O can't be skipped without knowing in advance it'll be superseded. Verification: full app unit test suite green, including two new regression tests for the generation-capture fix (confirming a stale dialog surfaces success without Finish-ing over a newer request; ignoring a stale dialog doesn't Finish over a newer one) and a strengthened suggestUniqueBaseName test asserting the give-up candidate was actually checked. On-device (R5CN80KZCKD): fresh install and overwrite (via the new moveFile()) both verified with a real .cgt archive - correct dialog content, clean success, no crash, no stray .tmp/.bak files left in the templates directory. Co-Authored-By: Claude Sonnet 5 --- .../TemplateCollectionRepositoryImpl.kt | 61 +++++++-------- .../ExternalFileInstallViewModel.kt | 64 ++++++++++++---- .../viewmodels/PluginManagerViewModel.kt | 7 +- .../ExternalFileInstallViewModelTest.kt | 75 +++++++++++++++++++ resources/src/main/res/values/strings.xml | 2 +- 5 files changed, 156 insertions(+), 53 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt index dced182e21..e62682f40e 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -38,6 +38,18 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { templatesDir .listFiles { file -> file.extension.equals(TEMPLATE_ARCHIVE_EXTENSION, ignoreCase = true) } ?.firstOrNull { it.nameWithoutExtension.equals(baseName, ignoreCase = true) } + + /** + * Renames [src] to [dst], falling back to copy+delete - renameTo() is unreliable on-device + * even for a same-directory move (confirmed during this PR). [src] is gone on success + * either way; left untouched on failure. + */ + private fun moveFile( + src: File, + dst: File, + ): Boolean = + src.renameTo(dst) || + runCatching { src.copyTo(dst, overwrite = true) }.isSuccess.also { copied -> if (copied) src.delete() } } override suspend fun inspectCollection(candidateFile: File): Result = @@ -140,52 +152,33 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { // Back up (rather than delete) any existing destFile, so it can be put back if // the swap below fails for any reason - the existing collection is only ever - // removed once the new one is confirmed successfully in its place. Same - // renameTo()-then-copyTo() fallback as the swap below: renameTo() is unreliable - // on-device even for a same-directory move (confirmed there during this PR). + // removed once the new one is confirmed successfully in its place. val hadExisting = destFile.exists() val backupFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.bak") - if (hadExisting) { - val backedUp = - destFile.renameTo(backupFile) || - runCatching { destFile.copyTo(backupFile, overwrite = true).also { destFile.delete() } }.isSuccess - if (!backedUp) { - stagingFile.delete() - throw IllegalStateException("Failed to back up existing file before replacing: ${destFile.name}") - } + if (hadExisting && !moveFile(destFile, backupFile)) { + stagingFile.delete() + throw IllegalStateException("Failed to back up existing file before replacing: ${destFile.name}") } // Both files are now on the same volume (templatesDir), so this is a cheap, // same-directory move - renameTo() failing here (as opposed to across the // temp/templates boundary candidateFile itself would have to cross) would be - // unexpected, but fall back to a copy anyway. - val swapSucceeded = - stagingFile.renameTo(destFile) || - runCatching { stagingFile.copyTo(destFile, overwrite = true) }.isSuccess - - if (!swapSucceeded) { - if (hadExisting) { - val restored = - backupFile.renameTo(destFile) || - runCatching { backupFile.copyTo(destFile, overwrite = true) }.isSuccess - if (!restored) { - // Nothing more we can do here - surface it loudly rather than silently - // leaving the user's original collection sitting under the backup's - // random filename, invisible to findExistingCollision(). - log.error( - "Failed to restore backup after a failed swap for \"{}\" - original content may still be at: {}", - destFile.name, - backupFile.name, - ) - } else { - backupFile.delete() - } + // unexpected, but moveFile() falls back to a copy anyway. + if (!moveFile(stagingFile, destFile)) { + if (hadExisting && !moveFile(backupFile, destFile)) { + // Nothing more we can do here - surface it loudly rather than silently + // leaving the user's original collection sitting under the backup's + // random filename, invisible to findExistingCollision(). + log.error( + "Failed to restore backup after a failed swap for \"{}\" - original content may still be at: {}", + destFile.name, + backupFile.name, + ) } stagingFile.delete() throw IllegalStateException("Failed to replace existing file: ${destFile.name}") } - stagingFile.delete() if (hadExisting && backupFile.exists() && !backupFile.delete()) { log.warn("Installed but failed to delete backup file: {}", backupFile.name) } diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index 377f333221..b49005742a 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -93,11 +93,24 @@ class ExternalFileInstallViewModel( // there's no ordering ambiguity about which file it refers to. private var pendingConfirmationTempFile: File? = null + // The generation pendingConfirmationTempFile actually belongs to - NOT necessarily + // currentRequestGeneration, which can already have moved on to a newer, still-in-flight + // request by the time the user taps a button on the dialog still on screen (its onReceived() + // bumped the counter synchronously, but hasn't reached supersedePendingConfirmation() yet). + // confirmTemplateInstall()/onEvent() must key off this, not the live counter, or a stale + // dialog's action gets misattributed to the newer request and can tear the Activity down out + // from under it. + private var pendingConfirmationGeneration: Int = 0 + private fun isCurrentGeneration(generation: Int) = generation == currentRequestGeneration - private suspend fun supersedePendingConfirmation(newPendingFile: File?) { + private suspend fun supersedePendingConfirmation( + newPendingFile: File?, + newGeneration: Int, + ) { pendingConfirmationTempFile?.let { old -> if (old != newPendingFile) deleteQuietly(old) } pendingConfirmationTempFile = newPendingFile + pendingConfirmationGeneration = newGeneration } /** Call once, from `Activity.onCreate()`/`onNewIntent()`, with the VIEW intent's data [Uri]. */ @@ -164,7 +177,7 @@ class ExternalFileInstallViewModel( // Forwarded as a plain path, not a content:// Uri: both activities run in this // same process and already trust filesDir paths, so PluginManagerViewModel can // install straight from this file instead of copying it a second time. - supersedePendingConfirmation(null) + supersedePendingConfirmation(null, generation) _uiEffect.trySend(ExternalFileInstallUiEffect.ForwardToPluginManager(tempFile.absolutePath)) } else { dispatchTemplateInstall(tempFile, baseName, generation) @@ -200,7 +213,7 @@ class ExternalFileInstallViewModel( return } - supersedePendingConfirmation(tempFile) + supersedePendingConfirmation(tempFile, generation) // This dialog's buttons must start enabled regardless of whether some earlier, // now-abandoned generation's install is still finishing up in the background (see // confirmTemplateInstall()'s own generation check for the other half of this). @@ -223,10 +236,16 @@ class ExternalFileInstallViewModel( } is ExternalFileInstallUiEvent.IgnoreTemplateInstall -> { - if (pendingConfirmationTempFile == event.tempFile) pendingConfirmationTempFile = null - viewModelScope.launch { - deleteQuietly(event.tempFile) - _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) + // If this doesn't match, the dialog this event was fired from has already been + // superseded (and its tempFile already deleted by supersedePendingConfirmation) - + // nothing left on screen to Finish, and Finish-ing anyway would tear down the + // Activity out from under whatever newer dialog is now showing. + if (pendingConfirmationTempFile == event.tempFile) { + pendingConfirmationTempFile = null + viewModelScope.launch { + deleteQuietly(event.tempFile) + _uiEffect.trySend(ExternalFileInstallUiEffect.Finish) + } } } } @@ -241,16 +260,21 @@ class ExternalFileInstallViewModel( // the second call's renameTo()/copyTo() would otherwise race the first's on the same // tempFile and surface a spurious failure toast. if (_isInstalling.value) return + // If this doesn't match, the dialog this event was fired from has already been superseded + // (its tempFile already deleted by supersedePendingConfirmation) - there's nothing left to + // install, and proceeding anyway would mean using currentRequestGeneration as this + // install's generation, misattributing it to whatever newer request bumped the counter. + if (pendingConfirmationTempFile != tempFile) return _isInstalling.value = true - // Captured now: if a newer onReceived() supersedes this one's dialog before this install - // finishes (see dispatchTemplateInstall()), this install must neither tear down the - // Activity out from under the newer dialog nor touch _isInstalling/pendingConfirmation - // state that by then belongs to a completely different request. - val generation = currentRequestGeneration + // The generation the now-showing dialog was committed under - NOT currentRequestGeneration, + // which may already have moved on to a newer, still-in-flight request (see + // pendingConfirmationGeneration's kdoc). This install must neither tear down the Activity + // out from under that newer request nor touch state that by then belongs to it. + val generation = pendingConfirmationGeneration // From here on, tempFile's fate is owned by this install attempt, not "a dialog awaiting // an answer" - a subsequent onReceived() for a different file must not delete it out from // under an install already in flight. - if (pendingConfirmationTempFile == tempFile) pendingConfirmationTempFile = null + pendingConfirmationTempFile = null viewModelScope.launch { templateCollectionRepository @@ -278,7 +302,12 @@ class ExternalFileInstallViewModel( // they can retry - e.g. pick a different name after a collision, or // Overwrite instead. If a newer request has since superseded this dialog, // there's nothing left on-screen to retry against, so skip ShowError too. - _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(R.string.msg_template_install_failed)) + _uiEffect.trySend( + ExternalFileInstallUiEffect.ShowError( + R.string.msg_template_install_failed, + listOf(exception.message ?: exception.javaClass.simpleName), + ), + ) } } if (isCurrentGeneration(generation)) { @@ -300,7 +329,10 @@ class ExternalFileInstallViewModel( suspend fun suggestUniqueBaseName(baseName: String): String { var candidate = baseName var suffix = 2 - while (suffix <= MAX_SUGGESTION_ATTEMPTS && templateCollectionRepository.findExistingCollision(candidate) != null) { + // Collision must be checked before the attempt-count bound, not after: checking + // `suffix <= MAX` first would let the bound short-circuit the very last candidate's + // collision check, silently returning it unverified once the cap is hit. + while (templateCollectionRepository.findExistingCollision(candidate) != null && suffix <= MAX_SUGGESTION_ATTEMPTS) { candidate = "$baseName ($suffix)" suffix++ } @@ -317,7 +349,7 @@ class ExternalFileInstallViewModel( // A stale (already-superseded) request never reaches here (see the isCurrentGeneration // check above), so whatever's still pending at this point genuinely belongs to an earlier, // now-being-terminated request and must be cleaned up rather than left dangling. - supersedePendingConfirmation(null) + supersedePendingConfirmation(null, generation) // See confirmTemplateInstall()'s onSuccess: the Screen suspends on ShowError until the // flashbar is actually shown before processing Finish, so no delay is needed here either. _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(messageResId)) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index ad768ebf31..17d9f5cebe 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -75,8 +75,11 @@ class PluginManagerViewModel( // Public read-only state val uiState: StateFlow = _uiState.asStateFlow() - // Channel for one-time UI effects - private val _uiEffect = Channel() + // Channel for one-time UI effects. Buffered (not rendezvous): a synchronous decision path + // (e.g. handlePendingInstallExtra()'s effect right after onCreate()/onNewIntent()) can + // otherwise complete before the Activity's collector actually attaches, silently dropping the + // effect - see ExternalFileInstallViewModel's identical reasoning for its own uiEffect. + private val _uiEffect = Channel(capacity = Channel.BUFFERED) val uiEffect = _uiEffect.receiveAsFlow() // Current operation tracking diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt index 8b75ec2ab7..dc4bb54673 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt @@ -11,6 +11,7 @@ import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent import com.itsaky.androidide.viewmodel.MainDispatcherRule import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -197,6 +198,77 @@ class ExternalFileInstallViewModelTest { assertThat(viewModel.isInstalling.value).isFalse() } + @Test + fun `confirming a stale dialog uses its own generation, not a newer request's`() = + runTest { + // Regression test: confirmTemplateInstall() must key off the generation the on-screen + // dialog was actually committed under (pendingConfirmationGeneration), not the live + // currentRequestGeneration counter, which a second onReceived() can already have bumped + // before its own dialog is shown. + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision("first") } returns null + coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returns Result.success(Unit) + + val secondGate = CompletableDeferred() + coEvery { templateCollectionRepository.findExistingCollision("second") } coAnswers { + secondGate.await() + null + } + + viewModel.onReceived(sourceUriFor("first.cgt")) + val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + val firstTempFile = firstEffect.tempFile + + // A second VIEW intent arrives (e.g. via onNewIntent) while file A's dialog is still + // the one on screen - this bumps currentRequestGeneration synchronously, well before + // file B's own async pipeline (gated on secondGate) can commit its own dialog. + viewModel.onReceived(sourceUriFor("second.cgt")) + + // The user taps Install on the still-visible (but now globally-stale) dialog for A. + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall(firstTempFile, firstEffect.suggestedBaseName, overwrite = false), + ) + + // File A's install genuinely succeeds - but must not Finish the Activity, since file + // B's request (a newer generation) is still in flight and hasn't shown its own dialog. + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowSuccess::class.java) + + secondGate.complete(Unit) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation::class.java) + } + + @Test + fun `ignoring a stale dialog does not finish the activity out from under a newer one`() = + runTest { + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returns Result.success(Unit) + + viewModel.onReceived(sourceUriFor("first.cgt")) + val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + val firstTempFile = firstEffect.tempFile + + viewModel.onReceived(sourceUriFor("second.cgt")) + val secondEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + + // A stale Ignore/Cancel tap for file A's now-replaced dialog must be a no-op - in + // particular it must not Finish the Activity out from under file B's current dialog. + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(firstTempFile)) + + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall( + secondEffect.tempFile, + secondEffect.suggestedBaseName, + overwrite = false, + ), + ) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowSuccess::class.java) + } + @Test fun `invalid cgt shows invalid-file error`() = runTest { @@ -263,6 +335,9 @@ class ExternalFileInstallViewModelTest { val suggested = viewModel.suggestUniqueBaseName("foo") assertThat(suggested).isEqualTo("foo (50)") + // The give-up candidate itself must actually have been checked for collision - not + // returned unverified because the attempt-count bound short-circuited before it. + coVerify(exactly = 1) { templateCollectionRepository.findExistingCollision("foo (50)") } } private fun stubPluginManagerAvailable(available: Boolean) { diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 0f78996442..15fa7d11b0 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1102,7 +1102,7 @@ New collection name "%1$s" installed successfully Invalid or corrupted template collection file. - Failed to install template collection. + Failed to install template collection: %1$s \n\nProject creation finished with warnings/errors. Open IDE Logs for details. From 074575a9a4623c9b43f09cc42a67d1bca2f7dab8 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 12:46:07 -0700 Subject: [PATCH 20/23] ADFA-4934: Address new CodeRabbit findings on PR #1682 - TemplateCollectionRepositoryImpl: installCollection() had no per-destination-name locking, so two concurrent installs targeting the same case-insensitive base name could both pass the collision check before either wrote destFile, and the later swap would silently clobber the earlier one. Wrapped the whole operation in a Mutex keyed by the lowercased target base name (independently flagged by both CodeRabbit and the prior code-review round, which this addresses). - InstallTempFiles: newTempFile() ran mkdirs() and its periodic directory sweep/delete on whatever dispatcher the caller happened to be on - ExternalFileInstallViewModel.onReceived() called it without a surrounding withContext(Dispatchers.IO), so that filesystem work ran on the main thread. Made newTempFile() suspend and dispatch to Dispatchers.IO internally, so no caller can repeat the mistake. Not changed (already-deliberated design decisions / false positive): - CodeRabbit suggested a superseded install's completion should suppress its ShowSuccess effect entirely, since it can render over a newer request's dialog. This was already addressed in the prior commit by including the collection's name in the message so the toast is unambiguous regardless of what's currently on screen - suppressing it outright would mean a genuinely successful install never gets reported to the user. Replied on the thread with this reasoning. - CodeRabbit's JUnit Jupiter migration suggestion doesn't correspond to any actual change in this PR - both flagged test files still use @RunWith(RobolectricTestRunner::class) and plain JUnit4 @Test/runTest, unchanged. Replied noting this appears to be a false positive. Verification: full app unit test suite green. On-device (R5CN80KZCKD): fresh install of a real .cgt archive after these changes - correct dialog, clean success, no crash, no stray .tmp/.bak files in the templates directory. Co-Authored-By: Claude Sonnet 5 --- .../TemplateCollectionRepositoryImpl.kt | 207 ++++++++++-------- .../androidide/utils/InstallTempFiles.kt | 22 +- 2 files changed, 125 insertions(+), 104 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt index e62682f40e..c3900bbac7 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt @@ -7,12 +7,15 @@ import com.itsaky.androidide.templates.impl.zip.ZipTemplateReader import com.itsaky.androidide.utils.Environment import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION import org.adfa.constants.TEMPLATE_CORE_ARCHIVE import org.slf4j.LoggerFactory import java.io.File import java.util.UUID +import java.util.concurrent.ConcurrentHashMap /** * Implementation of [TemplateCollectionRepository]. Templates are pure data (a zip archive @@ -50,6 +53,14 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { ): Boolean = src.renameTo(dst) || runCatching { src.copyTo(dst, overwrite = true) }.isSuccess.also { copied -> if (copied) src.delete() } + + // Serializes installCollection() calls targeting the same case-insensitive base name - + // random staging/backup filenames already prevent two concurrent installs from colliding + // on an intermediate path, but without this, both could still pass the collision check + // before either writes destFile, so the later swap would silently clobber the earlier one. + private val installLocks = ConcurrentHashMap() + + private fun installLock(baseName: String): Mutex = installLocks.computeIfAbsent(baseName.lowercase()) { Mutex() } } override suspend fun inspectCollection(candidateFile: File): Result = @@ -98,115 +109,117 @@ class TemplateCollectionRepositoryImpl : TemplateCollectionRepository { overwrite: Boolean, ): Result = withContext(Dispatchers.IO) { - runCatching { - if (targetBaseName.equals(RESERVED_BASE_NAME, ignoreCase = true)) { - throw IllegalStateException("\"$targetBaseName\" is a reserved name and cannot be used") - } + installLock(targetBaseName).withLock { + runCatching { + if (targetBaseName.equals(RESERVED_BASE_NAME, ignoreCase = true)) { + throw IllegalStateException("\"$targetBaseName\" is a reserved name and cannot be used") + } - // targetBaseName ends up as a single path segment below; reject anything that - // could make it span multiple segments (or escape templatesDir entirely) before - // it ever reaches a File constructor. - if (targetBaseName.isBlank() || - targetBaseName.contains('/') || - targetBaseName.contains('\\') || - targetBaseName == "." || - targetBaseName == ".." - ) { - throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"") - } + // targetBaseName ends up as a single path segment below; reject anything that + // could make it span multiple segments (or escape templatesDir entirely) before + // it ever reaches a File constructor. + if (targetBaseName.isBlank() || + targetBaseName.contains('/') || + targetBaseName.contains('\\') || + targetBaseName == "." || + targetBaseName == ".." + ) { + throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"") + } - val templatesDir = - Environment.TEMPLATES_DIR - ?: throw IllegalStateException("Templates system not available") - - // Reuse the same case-insensitive lookup findExistingCollision() uses, so a - // case-variant match (e.g. installing "mytemplates" when "MyTemplates.cgt" is - // already there) is caught here too instead of silently creating a duplicate. - val existingMatch = findCollisionFile(templatesDir, targetBaseName) - if (existingMatch != null && !overwrite) { - throw IllegalStateException( - "A template collection named \"$targetBaseName\" already exists", - ) - } + val templatesDir = + Environment.TEMPLATES_DIR + ?: throw IllegalStateException("Templates system not available") + + // Reuse the same case-insensitive lookup findExistingCollision() uses, so a + // case-variant match (e.g. installing "mytemplates" when "MyTemplates.cgt" is + // already there) is caught here too instead of silently creating a duplicate. + val existingMatch = findCollisionFile(templatesDir, targetBaseName) + if (existingMatch != null && !overwrite) { + throw IllegalStateException( + "A template collection named \"$targetBaseName\" already exists", + ) + } - // Overwrite the existing case-variant file in place (preserving its casing) - // rather than create a second, case-differing duplicate. - val destFile = existingMatch ?: File(templatesDir, "$targetBaseName.$TEMPLATE_ARCHIVE_EXTENSION") + // Overwrite the existing case-variant file in place (preserving its casing) + // rather than create a second, case-differing duplicate. + val destFile = existingMatch ?: File(templatesDir, "$targetBaseName.$TEMPLATE_ARCHIVE_EXTENSION") - // Belt-and-braces against the character check above: confirm the resolved path - // still lands directly inside templatesDir once symlinks/".." are resolved. - if (destFile.canonicalFile.parentFile != templatesDir.canonicalFile) { - throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"") - } + // Belt-and-braces against the character check above: confirm the resolved path + // still lands directly inside templatesDir once symlinks/".." are resolved. + if (destFile.canonicalFile.parentFile != templatesDir.canonicalFile) { + throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"") + } - // Stage a copy of the incoming archive fully under templatesDir before touching - // destFile, so a failure while writing the new content never destroys the - // existing collection. candidateFile itself is deliberately left alone here (not - // moved/deleted) so that if anything below fails, the caller can retry the whole - // call with the same file - it's only deleted once the swap and the provider - // reload have both fully succeeded. The staging (and backup) filenames carry a - // random suffix so two concurrent installCollection() calls targeting the same - // destFile never race on the same intermediate path. - val stagingFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.tmp") - candidateFile.copyTo(stagingFile, overwrite = true) - - // Back up (rather than delete) any existing destFile, so it can be put back if - // the swap below fails for any reason - the existing collection is only ever - // removed once the new one is confirmed successfully in its place. - val hadExisting = destFile.exists() - val backupFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.bak") - if (hadExisting && !moveFile(destFile, backupFile)) { - stagingFile.delete() - throw IllegalStateException("Failed to back up existing file before replacing: ${destFile.name}") - } + // Stage a copy of the incoming archive fully under templatesDir before touching + // destFile, so a failure while writing the new content never destroys the + // existing collection. candidateFile itself is deliberately left alone here (not + // moved/deleted) so that if anything below fails, the caller can retry the whole + // call with the same file - it's only deleted once the swap and the provider + // reload have both fully succeeded. The staging (and backup) filenames carry a + // random suffix so two concurrent installCollection() calls targeting the same + // destFile never race on the same intermediate path. + val stagingFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.tmp") + candidateFile.copyTo(stagingFile, overwrite = true) + + // Back up (rather than delete) any existing destFile, so it can be put back if + // the swap below fails for any reason - the existing collection is only ever + // removed once the new one is confirmed successfully in its place. + val hadExisting = destFile.exists() + val backupFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.bak") + if (hadExisting && !moveFile(destFile, backupFile)) { + stagingFile.delete() + throw IllegalStateException("Failed to back up existing file before replacing: ${destFile.name}") + } + + // Both files are now on the same volume (templatesDir), so this is a cheap, + // same-directory move - renameTo() failing here (as opposed to across the + // temp/templates boundary candidateFile itself would have to cross) would be + // unexpected, but moveFile() falls back to a copy anyway. + if (!moveFile(stagingFile, destFile)) { + if (hadExisting && !moveFile(backupFile, destFile)) { + // Nothing more we can do here - surface it loudly rather than silently + // leaving the user's original collection sitting under the backup's + // random filename, invisible to findExistingCollision(). + log.error( + "Failed to restore backup after a failed swap for \"{}\" - original content may still be at: {}", + destFile.name, + backupFile.name, + ) + } + stagingFile.delete() + throw IllegalStateException("Failed to replace existing file: ${destFile.name}") + } - // Both files are now on the same volume (templatesDir), so this is a cheap, - // same-directory move - renameTo() failing here (as opposed to across the - // temp/templates boundary candidateFile itself would have to cross) would be - // unexpected, but moveFile() falls back to a copy anyway. - if (!moveFile(stagingFile, destFile)) { - if (hadExisting && !moveFile(backupFile, destFile)) { - // Nothing more we can do here - surface it loudly rather than silently - // leaving the user's original collection sitting under the backup's - // random filename, invisible to findExistingCollision(). + if (hadExisting && backupFile.exists() && !backupFile.delete()) { + log.warn("Installed but failed to delete backup file: {}", backupFile.name) + } + + // The file swap above is the operation's real postcondition - it already fully + // succeeded by this point. A reload failure here (e.g. templatesDir briefly + // unreadable) shouldn't turn that into a reported failure: doing so would leave + // destFile installed on disk while the caller believes nothing happened and + // retries, immediately hitting a spurious "already exists". + try { + ITemplateProvider.getInstance(reload = true) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { log.error( - "Failed to restore backup after a failed swap for \"{}\" - original content may still be at: {}", + "Template collection installed but the provider failed to reload: {}", destFile.name, - backupFile.name, + e, ) } - stagingFile.delete() - throw IllegalStateException("Failed to replace existing file: ${destFile.name}") - } - if (hadExisting && backupFile.exists() && !backupFile.delete()) { - log.warn("Installed but failed to delete backup file: {}", backupFile.name) - } - - // The file swap above is the operation's real postcondition - it already fully - // succeeded by this point. A reload failure here (e.g. templatesDir briefly - // unreadable) shouldn't turn that into a reported failure: doing so would leave - // destFile installed on disk while the caller believes nothing happened and - // retries, immediately hitting a spurious "already exists". - try { - ITemplateProvider.getInstance(reload = true) - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - log.error( - "Template collection installed but the provider failed to reload: {}", - destFile.name, - e, - ) - } - - if (!candidateFile.delete()) { - log.warn("Installed but failed to delete source temp file: {}", candidateFile.name) + if (!candidateFile.delete()) { + log.warn("Installed but failed to delete source temp file: {}", candidateFile.name) + } + Unit + }.onFailure { exception -> + if (exception is CancellationException) throw exception + log.error("Failed to install template collection: {}", candidateFile.name, exception) } - Unit - }.onFailure { exception -> - if (exception is CancellationException) throw exception - log.error("Failed to install template collection: {}", candidateFile.name, exception) } } diff --git a/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt b/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt index 421e6d7195..7c5b779269 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt @@ -1,5 +1,7 @@ package com.itsaky.androidide.utils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File import java.util.UUID import java.util.concurrent.TimeUnit @@ -23,16 +25,22 @@ object InstallTempFiles { private val SWEEP_INTERVAL_MS = TimeUnit.MINUTES.toMillis(10) private val lastSweepAtMs = AtomicLong(0L) - /** Creates a uniquely-named `_.` file under `filesDir/temp`. */ - fun newTempFile( + /** + * Creates a uniquely-named `_.` file under `filesDir/temp`. Suspends + * and dispatches to [Dispatchers.IO] internally - mkdirs() and the periodic directory + * sweep/delete below are real filesystem work, so callers don't need their own withContext to + * keep this off the caller's (possibly Main) dispatcher. + */ + suspend fun newTempFile( filesDir: File, prefix: String, extension: String, - ): File { - val tempDir = File(filesDir, "temp").apply { mkdirs() } - sweepStaleIfDue(tempDir) - return File(tempDir, "${prefix}_${UUID.randomUUID()}.$extension") - } + ): File = + withContext(Dispatchers.IO) { + val tempDir = File(filesDir, "temp").apply { mkdirs() } + sweepStaleIfDue(tempDir) + File(tempDir, "${prefix}_${UUID.randomUUID()}.$extension") + } private fun sweepStaleIfDue(tempDir: File) { val now = System.currentTimeMillis() From d6eb6d04b4b185f79c4fce8a9c508753e8a05471 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 13:20:05 -0700 Subject: [PATCH 21/23] ADFA-4934: Fix a self-inflicted stuck-dialog regression from the sixth review round - ExternalFileInstallViewModel: confirmTemplateInstall() clears pendingConfirmationTempFile on entry (transferring tempFile's "ownership" to the install attempt, per the sixth-round generation fix), but never restored it on install failure. The dialog is deliberately left open so the user can retry - but with pendingConfirmationTempFile left null, every subsequent tap (Install/Overwrite/Rename again, or Cancel/back) silently no-ops forever, since both confirmTemplateInstall() and IgnoreTemplateInstall key off it matching. The excludeFromRecents=true trampoline Activity has no other way out at that point short of force-stopping the app. Fixed by restoring pendingConfirmationTempFile/Generation in the onFailure branch (gated on isCurrentGeneration, same as everything else there) so a retry or cancel on the still-open dialog matches again. - ApkInstaller: isValidApk's extension check was case-sensitive (`== "apk"`), inconsistent with every other extension check this PR touched. An uppercase .APK (common from browsers/email/file managers that preserve sender casing) silently failed with no error shown. Not changed (pre-existing gaps, not regressions from this PR, larger lifts than warranted at this point): - InstallFileAction's file-tab "Install" for .cgp calls PluginRepository.installPluginFromFile() directly, bypassing the signature-mismatch/overwrite-confirmation check every other plugin install entry point goes through - pre-existing behavior, would need routing this action through the same ViewModel-level conflict resolution. - PluginRepositoryImpl.installPluginFromFile has no backup/rollback or per-target locking, unlike TemplateCollectionRepositoryImpl - a structurally similar but substantially larger lift given plugin install's uninstall/restart semantics. - Two distinct .cgp files forwarded to an already-open PluginManagerActivity in quick succession can each pass markPendingInstallHandled's per-value dedup and stack two native AlertDialogs - would need the same generation-tracking machinery ExternalFileInstallViewModel has, ported to the plugin flow's more complex dialog chain. Verification: full app unit test suite green, including two new regression tests (retrying Install after a failed install actually re-attempts it; cancelling after a failed install still finishes) that fail against the pre-fix code and pass against the fix. Co-Authored-By: Claude Sonnet 5 --- .../itsaky/androidide/utils/ApkInstaller.kt | 2 +- .../ExternalFileInstallViewModel.kt | 6 ++ .../ExternalFileInstallViewModelTest.kt | 56 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt index 12732686bc..3d1ca6a776 100644 --- a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt +++ b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt @@ -40,7 +40,7 @@ object ApkInstaller { ): Boolean { val isValidApk = withContext(Dispatchers.IO) { - apk.exists() && apk.isFile && apk.extension == "apk" + apk.exists() && apk.isFile && apk.extension.equals("apk", ignoreCase = true) } if (!isValidApk) { log.error("File is not an APK: {}", apk) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt index b49005742a..f2bb0c6dc3 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt @@ -302,6 +302,12 @@ class ExternalFileInstallViewModel( // they can retry - e.g. pick a different name after a collision, or // Overwrite instead. If a newer request has since superseded this dialog, // there's nothing left on-screen to retry against, so skip ShowError too. + // Restore pendingConfirmationTempFile/Generation (cleared above on entry): + // a retry tap or Cancel/back on this still-open dialog must match again, or + // confirmTemplateInstall()/IgnoreTemplateInstall's guards would treat every + // button on it as a permanent no-op from here on. + pendingConfirmationTempFile = tempFile + pendingConfirmationGeneration = generation _uiEffect.trySend( ExternalFileInstallUiEffect.ShowError( R.string.msg_template_install_failed, diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt index dc4bb54673..fbf2784b15 100644 --- a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt +++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt @@ -269,6 +269,62 @@ class ExternalFileInstallViewModelTest { assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowSuccess::class.java) } + @Test + fun `retrying Install after a failed install actually attempts install again`() = + runTest { + // Regression test: confirmTemplateInstall() clears pendingConfirmationTempFile on + // entry (transferring tempFile's "ownership" to the install attempt) but the dialog is + // deliberately left open on failure so the user can retry - if that field isn't + // restored, the retry tap's pendingConfirmationTempFile != tempFile guard silently + // no-ops forever, permanently stranding the user on an unresponsive dialog. + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returnsMany + listOf(Result.failure(IllegalStateException("disk full")), Result.success(Unit)) + + viewModel.onReceived(sourceUriFor("first.cgt")) + val effect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall(effect.tempFile, effect.suggestedBaseName, overwrite = false), + ) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + + // Retry tap on the still-open dialog must actually attempt the install again, not + // silently no-op. + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall(effect.tempFile, effect.suggestedBaseName, overwrite = false), + ) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowSuccess::class.java) + coVerify(exactly = 2) { templateCollectionRepository.installCollection(any(), any(), any()) } + } + + @Test + fun `cancelling after a failed install still finishes`() = + runTest { + stubTemplatesFeatureAvailable(true) + val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity")) + coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info) + coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null + coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returns + Result.failure(IllegalStateException("disk full")) + + viewModel.onReceived(sourceUriFor("first.cgt")) + val effect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation + + viewModel.onEvent( + ExternalFileInstallUiEvent.ConfirmTemplateInstall(effect.tempFile, effect.suggestedBaseName, overwrite = false), + ) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java) + + // Cancel/back on the still-open dialog after a failed install must still Finish, not + // silently no-op. + viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile)) + assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.Finish::class.java) + } + @Test fun `invalid cgt shows invalid-file error`() = runTest { From 400e767569fc89a0bd785b243d64009c883ff50a Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 13:29:33 -0700 Subject: [PATCH 22/23] ADFA-4934: Stack the name-conflict dialog's three buttons vertically AlertDialog's default confirmButton/dismissButton row can't fit three actions (Overwrite / Rename & Install / Cancel) on one line, so it wrapped awkwardly - one button alone on the first row, the other two crammed together on a second row. Moved all three into the confirmButton slot as a right-aligned Column instead, so they stack one per row. Co-Authored-By: Claude Sonnet 5 --- .../activities/ExternalFileInstallScreen.kt | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt index 129bcb9d1c..16f86299d2 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt @@ -2,7 +2,7 @@ package com.itsaky.androidide.activities import android.app.Activity import android.content.Intent -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Column import androidx.compose.material3.AlertDialog import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -14,6 +14,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -238,13 +239,15 @@ private fun NameConflictDialog( ), ) }, + // Three actions don't fit in AlertDialog's default single-row confirm/dismiss layout + // without wrapping awkwardly (e.g. two buttons stacked oddly against the third) - stack + // them vertically instead, right-aligned, all within the confirmButton slot (dismissButton + // left unset). confirmButton = { - TextButton(onClick = onOverwrite, enabled = installEnabled) { Text(stringResource(R.string.btn_overwrite)) } - }, - dismissButton = { - Row { - TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) } + Column(horizontalAlignment = Alignment.End) { + TextButton(onClick = onOverwrite, enabled = installEnabled) { Text(stringResource(R.string.btn_overwrite)) } TextButton(onClick = onRename, enabled = installEnabled) { Text(stringResource(R.string.btn_rename_and_install)) } + TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) } } }, ) From dfb0840a66e0ccfb1c48d1b99cd36a58d1addc56 Mon Sep 17 00:00:00 2001 From: David Schachter Date: Mon, 17 Aug 2026 21:20:57 -0700 Subject: [PATCH 23/23] ADFA-4934: Add a mimeType-only manifest fallback for opaque content:// Uris QA (Daniel Alome, ticket comment) found that opening a real .cgp attachment from the Files app failed silently: Storage Access Framework providers (Android's Downloads app, most file managers) hand out opaque document IDs like content://.../document/msf%3A19, with no filename anywhere in the Uri. Every existing intent-filter here requires a pathPattern match, so none of them can ever match this - the OS instead fell through to an unrelated app that happened to declare an unconstrained VIEW+content+application/octet-stream filter (Google Pay's pkpass handler), which claimed the single unambiguous match and opened/closed with no chooser and no visible error. This exact tradeoff was already called out as a "known limitation, not fixable via manifest matching" in this file's own comment, on the reasoning that the only pathPattern-less alternative was mimeType="*/*" - which would register this app as a candidate for every file view intent on the device. That reasoning missed a middle ground: a pathPattern-less filter matching only the small, specific set of mimeTypes a binary/zip attachment actually carries (application/octet-stream, application/zip, application/x-zip-compressed) is narrow enough to be worth it. Added as a fourth intent-filter block and updated the manifest's own "known limitations" comment accordingly. This does mean the app now offers itself as an "Open with" candidate for any octet-stream/zip content from any app, not just .cgp/.cgt - accepted since the real extension is still re-validated from DISPLAY_NAME once opened (ExternalFileInstallViewModel.onReceived), so a mismatched file is rejected gracefully rather than mishandled. Verification: `./gradlew :app:processV8DebugMainManifest` succeeds. On a physical device (R5CN80KZCKD), simulated a real Downloads-provider- style opaque content Uri (content://com.android.providers.downloads. documents/document/msf%3A999, type application/octet-stream) via `am start` - confirmed via logcat/dumpsys and a screenshot that "Code on the Go" now appears in the "Open with" chooser, where before this fix it was completely absent from the candidate list (reproducing exactly the bug QA reported). No crash when actually opened. Co-Authored-By: Claude Sonnet 5 --- app/src/main/AndroidManifest.xml | 39 +++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 23187ef5b7..521b7867a1 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -144,14 +144,25 @@ re-exported from Windows tools) produce uppercase extensions. android:host="*" is required alongside pathPattern - the manifest matcher only evaluates pathPattern when host is also present. - Known limitations, not fixable via manifest matching: - (1) a sender whose content:// Uri path never carries the filename/extension at all - (some email providers' attachment Uris look like - content://.../message_attachment/12345/0/ATTACHMENT/false) can't match a - pathPattern-based filter regardless of type. The only alternative - a pathPattern- - less, mimeType="*/*" filter - would register this app as a candidate handler for - every file view intent on the device, which is a worse tradeoff than missing those - senders. + Known limitations: + (1) a sender whose content:// Uri path never carries the filename/extension at all - + the common case in practice, not an edge case: Storage Access Framework providers + (Android's Downloads app, most file managers) hand out opaque document IDs like + content://com.android.providers.downloads.documents/document/msf%3A19, and some email + providers' attachment Uris look like + content://.../message_attachment/12345/0/ATTACHMENT/false - can't match a + pathPattern-based filter regardless of type (confirmed via ADFA-4934's QA pass: the + OS fell through to an unrelated app that happened to declare a broader, unconstrained + VIEW+content+application/octet-stream filter, since ours never matched at all). + Mitigated (not fully fixed) below by a fourth, pathPattern-less filter matching only + the small set of mimeTypes such senders actually use for a binary/zip attachment + (application/octet-stream, application/zip, application/x-zip-compressed) - narrower + than the mimeType="*/*" this same reasoning previously rejected (that would register + this app as a candidate handler for every file view intent on the device), but still + broader than just .cgp/.cgt: any octet-stream/zip content from any app now offers this + app as an "Open with" candidate. Accepted since the real extension is re-validated + from DISPLAY_NAME once opened (ExternalFileInstallViewModel.onReceived) - a mismatched + file is rejected with msg_unsupported_file_type, not a crash. (2) only the fully-lowercase and fully-UPPERCASE forms are covered per extension - a mixed-case extension (e.g. "Plugin.Cgp") matches neither, since pathPattern's matcher supports only literal characters, '.', and '*' (no character classes), and @@ -203,6 +214,18 @@ + + + + + + + + +