From 7e24df31d8ecf66c3b6d0742a42c3ae56a93a989 Mon Sep 17 00:00:00 2001 From: yaturner Date: Wed, 29 Jul 2026 22:26:12 -0700 Subject: [PATCH 01/16] ADFA-4928: Wire up Jetpack Compose in the app module Adds the Compose plugin/buildFeatures/dependencies to app/build.gradle.kts, mirroring the floating-window/profiler modules' setup, plus a shared ManagerTheme composable that resolves Theme.AndroidIDE's Material3 attrs (same technique as FloatingTheme). This is the first commit of the Plugin Manager + Template Manager merge (ADR 0009 requires new screens to be Compose); the theme/build wiring lands separately from any screen code so it's independently reviewable and buildable. --- app/build.gradle.kts | 16 ++ .../androidide/adapters/PluginListAdapter.kt | 154 ------------------ .../ui/compose/theme/ManagerTheme.kt | 59 +++++++ .../main/res/layout/dialog_install_plugin.xml | 17 -- app/src/main/res/layout/item_plugin.xml | 106 ------------ app/src/main/res/menu/menu_plugin_manager.xml | 11 -- gradle/libs.versions.toml | 1 + 7 files changed, 76 insertions(+), 288 deletions(-) delete mode 100644 app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/theme/ManagerTheme.kt delete mode 100644 app/src/main/res/layout/dialog_install_plugin.xml delete mode 100644 app/src/main/res/layout/item_plugin.xml delete mode 100644 app/src/main/res/menu/menu_plugin_manager.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 90545a5698..60a56c0acb 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 = @@ -180,6 +181,10 @@ android { targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } + + buildFeatures { + compose = true + } } // Sentry gradle plugin config (crash reporting to GlitchTip). @@ -263,6 +268,17 @@ dependencies { implementation(libs.google.flexbox) implementation(libs.libsu.core) + // Compose (plugin/template manager screen; ADR 0009) + 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) + implementation(libs.androidx.lifecycle.runtime.compose) + debugImplementation(libs.compose.ui.tooling) + // Kotlin implementation(libs.androidx.core.ktx) implementation(libs.common.kotlin) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt deleted file mode 100644 index e9d7b2c652..0000000000 --- a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt +++ /dev/null @@ -1,154 +0,0 @@ - -package com.itsaky.androidide.adapters - -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.PopupMenu -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide -import com.itsaky.androidide.R -import com.itsaky.androidide.databinding.ItemPluginBinding -import com.itsaky.androidide.idetooltips.TooltipManager -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.plugins.PluginInfo -import com.itsaky.androidide.utils.isSystemInDarkMode -import java.io.File - -class PluginListAdapter( - private val onActionClick: (PluginInfo, Action) -> Unit -) : ListAdapter(PluginDiffCallback()) { - - enum class Action { - ENABLE, - DISABLE, - UNINSTALL, - DETAILS - } - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PluginViewHolder { - val binding = ItemPluginBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) - return PluginViewHolder(binding) - } - - override fun onBindViewHolder(holder: PluginViewHolder, position: Int) { - holder.bind(getItem(position)) - } - - inner class PluginViewHolder( - private val binding: ItemPluginBinding - ) : RecyclerView.ViewHolder(binding.root) { - - fun bind(plugin: PluginInfo) { - binding.apply { - pluginName.text = plugin.metadata.name - pluginDescription.text = plugin.metadata.description - val version = plugin.metadata.version - val segments = version.split('.') - pluginVersion.text = if (segments.size > 3) { - "v${segments.take(3).joinToString(".")}..." - } else { - "v$version" - } - pluginAuthor.text = "by ${plugin.metadata.author}" - - val iconPath = if (itemView.context.isSystemInDarkMode()) { - plugin.metadata.iconNightPath - } else { - plugin.metadata.iconDayPath - } - - pluginIcon.background = null - pluginIcon.imageTintList = null - val iconFile = iconPath?.let(::File)?.takeIf { it.exists() } - if (iconFile != null) { - Glide.with(pluginIcon) - .load(iconFile) - .placeholder(R.drawable.ic_extension) - .error(R.drawable.ic_extension) - .into(pluginIcon) - } else { - Glide.with(pluginIcon).clear(pluginIcon) - pluginIcon.setImageResource(R.drawable.ic_extension) - } - - // Set status - val statusText = when { - !plugin.isLoaded -> "Not Loaded" - !plugin.isEnabled -> "Disabled" - else -> "Enabled" - } - pluginStatus.text = statusText - - // Set status color - val statusColor = when { - !plugin.isLoaded -> R.color.error - !plugin.isEnabled -> R.color.warning - else -> R.color.success - } - pluginStatus.setTextColor( - itemView.context.getColor(statusColor) - ) - - // Setup menu button - btnMenu.setOnClickListener { view -> - showPopupMenu(view, plugin) - } - - // Setup item click for details - root.setOnClickListener { - onActionClick(plugin, Action.DETAILS) - } - - // Long-press for Plugin Manager tooltip - root.setOnLongClickListener { - TooltipManager.showIdeCategoryTooltip(it.context, it, TooltipTag.PLUGIN_MANAGER) - true - } - } - } - - private fun showPopupMenu(view: View, plugin: PluginInfo) { - val popup = PopupMenu(view.context, view) - - // Add menu items based on plugin state - if (plugin.isLoaded) { - if (plugin.isEnabled) { - popup.menu.add(0, 1, 0, "Disable") - } else { - popup.menu.add(0, 2, 0, "Enable") - } - popup.menu.add(0, 3, 0, "Uninstall") - } - popup.menu.add(0, 4, 0, "Details") - - popup.setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - 1 -> onActionClick(plugin, Action.DISABLE) - 2 -> onActionClick(plugin, Action.ENABLE) - 3 -> onActionClick(plugin, Action.UNINSTALL) - 4 -> onActionClick(plugin, Action.DETAILS) - } - true - } - - popup.show() - } - } -} - -class PluginDiffCallback : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: PluginInfo, newItem: PluginInfo): Boolean { - return oldItem.metadata.id == newItem.metadata.id - } - - override fun areContentsTheSame(oldItem: PluginInfo, newItem: PluginInfo): Boolean { - return oldItem == newItem - } -} \ No newline at end of file diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/theme/ManagerTheme.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/theme/ManagerTheme.kt new file mode 100644 index 0000000000..e5b5ae8c27 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/theme/ManagerTheme.kt @@ -0,0 +1,59 @@ +package com.itsaky.androidide.ui.compose.theme + +import android.content.Context +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.google.android.material.color.MaterialColors +import com.google.android.material.R as MatR + +private const val UNRESOLVED_COLOR = Int.MIN_VALUE + +/** + * Wraps manager-screen content (plugin/template manager) in a [MaterialTheme] whose colors are + * read live from the IDE's XML `Theme.AndroidIDE`, so this first Compose screen in `app` stays + * visually consistent with the surrounding View-based UI, including light/dark and the + * BlueWave/SunnyGlow theme variants (all of which override the same Material attrs). + */ +@Composable +fun ManagerTheme(content: @Composable () -> Unit) { + val context = LocalContext.current + val dark = isSystemInDarkTheme() + val colorScheme = remember(context, dark) { context.toComposeColorScheme(dark) } + MaterialTheme(colorScheme = colorScheme, content = content) +} + +private fun Context.toComposeColorScheme(dark: Boolean): ColorScheme { + val base = if (dark) darkColorScheme() else lightColorScheme() + + fun color( + attr: Int, + fallback: Color, + ): Color { + val resolved = MaterialColors.getColor(this, attr, UNRESOLVED_COLOR) + return if (resolved == UNRESOLVED_COLOR) fallback else Color(resolved) + } + + return base.copy( + primary = color(MatR.attr.colorPrimary, base.primary), + onPrimary = color(MatR.attr.colorOnPrimary, base.onPrimary), + primaryContainer = color(MatR.attr.colorPrimaryContainer, base.primaryContainer), + onPrimaryContainer = color(MatR.attr.colorOnPrimaryContainer, base.onPrimaryContainer), + secondary = color(MatR.attr.colorSecondary, base.secondary), + onSecondary = color(MatR.attr.colorOnSecondary, base.onSecondary), + surface = color(MatR.attr.colorSurface, base.surface), + onSurface = color(MatR.attr.colorOnSurface, base.onSurface), + surfaceVariant = color(MatR.attr.colorSurfaceVariant, base.surfaceVariant), + onSurfaceVariant = color(MatR.attr.colorOnSurfaceVariant, base.onSurfaceVariant), + outline = color(MatR.attr.colorOutline, base.outline), + error = color(MatR.attr.colorError, base.error), + onError = color(MatR.attr.colorOnError, base.onError), + background = color(android.R.attr.colorBackground, base.background), + ) +} diff --git a/app/src/main/res/layout/dialog_install_plugin.xml b/app/src/main/res/layout/dialog_install_plugin.xml deleted file mode 100644 index 54824f6dec..0000000000 --- a/app/src/main/res/layout/dialog_install_plugin.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/item_plugin.xml b/app/src/main/res/layout/item_plugin.xml deleted file mode 100644 index 6ca63b9e41..0000000000 --- a/app/src/main/res/layout/item_plugin.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/menu/menu_plugin_manager.xml b/app/src/main/res/menu/menu_plugin_manager.xml deleted file mode 100644 index d68857f07b..0000000000 --- a/app/src/main/res/menu/menu_plugin_manager.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9c4e15649b..f02f1c33fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -96,6 +96,7 @@ androidx-fragment = { module = "androidx.fragment:fragment", version.ref = "frag androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleViewmodelKtx" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleViewmodelKtx" } +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" } androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtxVersion" } androidx-recyclerview-v132 = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } From 2e71861cec0022538762f1cae48394bc607fbdb7 Mon Sep 17 00:00:00 2001 From: yaturner Date: Wed, 29 Jul 2026 22:26:55 -0700 Subject: [PATCH 02/16] ADFA-4928: Port Plugin Manager screen to Compose Rebuilds PluginManagerActivity's screen in Jetpack Compose (ADR 0009), preserving every capability of the old RecyclerView/dialogs UI: install via SAF picker, enable/disable/uninstall, overwrite and signature-mismatch conflict handling, restart prompt, and the discover-plugins action. PluginManagerViewModel/PluginRepository are reused unchanged. The six long-press tooltip anchor points collapse to two (list items, and the screen's background/empty state) since they all showed the same TooltipTag.PLUGIN_MANAGER content anyway - verified on-device that the long-press still correctly reaches TooltipManager. Also moves two dialogs' hardcoded English strings (uninstall confirmation, plugin details labels) into string resources. Note: taken together with the prior commit, this is the buildable/ tested state; the prior commit's PluginListAdapter.kt deletion was accidentally bundled with the build-wiring commit rather than this one, so that earlier commit alone doesn't compile in isolation - only the combined history does (verified via :app:assembleV8Debug and a manual on-device pass). --- .../activities/PluginManagerActivity.kt | 295 +--------------- .../androidide/ui/compose/common/FileImage.kt | 57 ++++ .../ui/compose/plugins/PluginListItem.kt | 155 +++++++++ .../compose/plugins/PluginManagerDialogs.kt | 131 +++++++ .../ui/compose/plugins/PluginManagerScreen.kt | 323 ++++++++++++++++++ .../res/layout/activity_plugin_manager.xml | 77 +---- resources/src/main/res/values/strings.xml | 12 + 7 files changed, 689 insertions(+), 361 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerScreen.kt 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..0aa0489259 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -2,83 +2,29 @@ package com.itsaky.androidide.activities -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.util.Log -import android.view.Menu -import android.view.MenuItem import android.view.View -import android.widget.CheckBox -import androidx.activity.result.contract.ActivityResultContracts import androidx.core.graphics.Insets -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R -import com.itsaky.androidide.adapters.PluginListAdapter import com.itsaky.androidide.app.EdgeToEdgeIDEActivity 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.PluginManagerUiEffect -import com.itsaky.androidide.ui.models.PluginManagerUiEvent -import com.itsaky.androidide.utils.DURATION_INDEFINITE -import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt -import com.itsaky.androidide.utils.UrlManager -import com.itsaky.androidide.utils.errorIcon +import com.itsaky.androidide.ui.compose.plugins.PluginManagerScreen +import com.itsaky.androidide.ui.compose.theme.ManagerTheme import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.flashSuccess -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.launch import org.koin.androidx.viewmodel.ext.android.viewModel class PluginManagerActivity : EdgeToEdgeIDEActivity() { - companion object { - private const val TAG = "PluginManagerActivity" - private const val PLUGIN_EXTENSION = ".cgp" - } - @Suppress("ktlint:standard:backing-property-naming") private var _binding: ActivityPluginManagerBinding? = null private val binding: ActivityPluginManagerBinding get() = checkNotNull(_binding) { "Activity has been destroyed" } - private lateinit var adapter: PluginListAdapter private var feedbackButtonManager: FeedbackButtonManager? = null private val viewModel: PluginManagerViewModel by viewModel() - private val pluginPickerLauncher = - registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> - uri?.let { - try { - contentResolver.takePersistableUriPermission( - it, - Intent.FLAG_GRANT_READ_URI_PERMISSION, - ) - } catch (e: SecurityException) { - Log.w(TAG, "Could not take persistable URI permission", e) - } - - if (!it.isSupportedPluginFile()) { - flashError(getString(R.string.msg_unsupported_plugin_file)) - return@let - } - - showInstallConfirmation(it) - } - } - override fun bindLayout(): View { _binding = ActivityPluginManagerBinding.inflate(layoutInflater) return binding.root @@ -88,21 +34,13 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { try { super.onCreate(savedInstanceState) - setSupportActionBar(binding.toolbar) - supportActionBar?.apply { - title = getString(R.string.title_plugin_manager) - setDisplayHomeAsUpEnabled(true) - } - - binding.toolbar.setNavigationOnClickListener { - onBackPressedDispatcher.onBackPressed() + binding.composeView.setContent { + ManagerTheme { + PluginManagerScreen(activity = this, viewModel = viewModel) + } } - setupRecyclerView() - setupFab() - setupTooltipLongPress() setupFeedbackButton() - observeViewModel() } catch (e: Exception) { // Log the error and finish the activity if something goes wrong e.printStackTrace() @@ -116,29 +54,6 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { feedbackButtonManager?.loadFabPosition() } - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_plugin_manager, menu) - binding.toolbar.post { - binding.toolbar.findViewById(R.id.action_discover_plugins)?.setOnLongClickListener { view -> - TooltipManager.showIdeCategoryTooltip(this, view, TooltipTag.PLUGIN_MANAGER) - true - } - } - return true - } - - override fun onOptionsItemSelected(item: MenuItem): Boolean = - when (item.itemId) { - R.id.action_discover_plugins -> { - UrlManager.openUrl(getString(R.string.url_discover_plugins), null, this) - true - } - - else -> { - super.onOptionsItemSelected(item) - } - } - override fun onDestroy() { super.onDestroy() _binding = null @@ -153,51 +68,6 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { ) } - private fun setupRecyclerView() { - adapter = - PluginListAdapter { plugin, action -> - when (action) { - PluginListAdapter.Action.ENABLE -> viewModel.onEvent(PluginManagerUiEvent.EnablePlugin(plugin.metadata.id)) - PluginListAdapter.Action.DISABLE -> viewModel.onEvent(PluginManagerUiEvent.DisablePlugin(plugin.metadata.id)) - PluginListAdapter.Action.UNINSTALL -> viewModel.onEvent(PluginManagerUiEvent.UninstallPlugin(plugin.metadata.id)) - PluginListAdapter.Action.DETAILS -> viewModel.onEvent(PluginManagerUiEvent.ShowPluginDetails(plugin)) - } - } - - binding.recyclerView.apply { - layoutManager = LinearLayoutManager(this@PluginManagerActivity) - adapter = this@PluginManagerActivity.adapter - } - } - - private fun setupFab() { - binding.fabInstallPlugin.setOnClickListener { - viewModel.onEvent(PluginManagerUiEvent.OpenFilePicker) - } - } - - private fun setupTooltipLongPress() { - val showTooltip: (View) -> Unit = { view -> - TooltipManager.showIdeCategoryTooltip(this, view, TooltipTag.PLUGIN_MANAGER) - } - binding.toolbar.setOnLongClickListener { - showTooltip(it) - true - } - binding.fabInstallPlugin.setOnLongClickListener { - showTooltip(it) - true - } - binding.emptyState.setOnLongClickListener { - showTooltip(it) - true - } - binding.recyclerView.setOnLongClickListener { - showTooltip(it) - true - } - } - private fun setupFeedbackButton() { feedbackButtonManager = FeedbackButtonManager( @@ -206,157 +76,4 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { ) feedbackButtonManager?.setupDraggableFab() } - - private fun observeViewModel() { - // Observe UI state - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiState.collect { state -> - updateUI(state) - } - } - } - - // Observe UI effects - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.uiEffect.collect { effect -> - handleUiEffect(effect) - } - } - } - } - - private fun updateUI(state: com.itsaky.androidide.ui.models.PluginManagerUiState) { - // Update plugin list - adapter.submitList(state.plugins) - - // Update empty state - if (state.showEmptyState) { - binding.recyclerView.visibility = View.GONE - binding.emptyState.visibility = View.VISIBLE - } else { - binding.recyclerView.visibility = View.VISIBLE - binding.emptyState.visibility = View.GONE - } - - // Update install button state - binding.fabInstallPlugin.isEnabled = !state.isInstalling - } - - private fun handleUiEffect(effect: PluginManagerUiEffect) { - when (effect) { - is PluginManagerUiEffect.ShowError -> { - val errorMessage = getString(effect.messageResId, *effect.formatArgs.toTypedArray()) - val builder = - flashbarBuilder(duration = if (effect.formatArgs.isEmpty()) 5000L else DURATION_INDEFINITE) - .errorIcon() - .message(errorMessage) - if (effect.formatArgs.isNotEmpty()) { - builder - .positiveActionText(R.string.copy) - .positiveActionTapListener { bar -> - (getSystemService(ClipboardManager::class.java)) - ?.setPrimaryClip(ClipData.newPlainText(getString(R.string.msg_plugin_error_clip_label), errorMessage)) - bar.dismiss() - } - } - builder.showOnUiThread() - } - - is PluginManagerUiEffect.ShowSuccess -> { - flashSuccess(getString(effect.messageResId)) - } - - is PluginManagerUiEffect.ShowPluginDetails -> { - showPluginDetails(effect.plugin) - } - - is PluginManagerUiEffect.OpenFilePicker -> { - openFilePicker() - } - - is PluginManagerUiEffect.ShowUninstallConfirmation -> { - showUninstallConfirmation(effect.plugin) - } - - is PluginManagerUiEffect.ShowRestartPrompt -> { - showRestartPrompt(this) - } - - is PluginManagerUiEffect.ShowOverwriteConfirmation -> { - showOverwriteConfirmation(effect) - } - } - } - - private fun openFilePicker() { - try { - pluginPickerLauncher.launch(arrayOf("*/*")) - } catch (_: Exception) { - flashError(getString(R.string.msg_no_file_manager)) - } - } - - private fun Uri.isSupportedPluginFile(): Boolean = getFileName(this@PluginManagerActivity).endsWith(PLUGIN_EXTENSION, ignoreCase = true) - - private fun showInstallConfirmation(uri: Uri) { - 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) - .setPositiveButton(R.string.btn_install) { _, _ -> - viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(uri, deleteCheckBox.isChecked)) - }.setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun showOverwriteConfirmation(effect: PluginManagerUiEffect.ShowOverwriteConfirmation) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.title_plugin_already_installed) - .setMessage( - getString( - R.string.msg_plugin_overwrite_confirm, - effect.existing.metadata.name, - effect.existing.metadata.version, - effect.incomingMetadata.version, - ), - ).setPositiveButton(R.string.replace) { _, _ -> - viewModel.onEvent( - PluginManagerUiEvent.ConfirmOverwrite(effect.uri, effect.deleteSourceAfterInstall), - ) - }.setNegativeButton(android.R.string.cancel, null) - .show() - } - - private fun showUninstallConfirmation(plugin: PluginInfo) { - MaterialAlertDialogBuilder(this) - .setTitle("Uninstall Plugin") - .setMessage("Are you sure you want to uninstall '${plugin.metadata.name}'?") - .setPositiveButton("Uninstall") { _, _ -> - viewModel.confirmUninstallPlugin(plugin.metadata.id) - }.setNegativeButton("Cancel", null) - .show() - } - - private fun showPluginDetails(plugin: PluginInfo) { - val details = - buildString { - append("Name: ${plugin.metadata.name}\n") - append("Plugin ID: ${plugin.metadata.id}\n") - append("Version: ${plugin.metadata.version}\n") - append("Author: ${plugin.metadata.author}\n") - append("Description: ${plugin.metadata.description}\n") - append("Min IDE Version: ${plugin.metadata.minIdeVersion}\n") - append("Permissions: ${plugin.metadata.permissions.joinToString(", ")}\n") - } - - MaterialAlertDialogBuilder(this) - .setTitle(plugin.metadata.name) - .setMessage(details) - .setPositiveButton("OK", null) - .show() - } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt new file mode 100644 index 0000000000..5d7a25506d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt @@ -0,0 +1,57 @@ +package com.itsaky.androidide.ui.compose.common + +import android.graphics.BitmapFactory +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File + +/** + * Renders [file] as an image, decoded off the main thread, falling back to [placeholder] while + * loading, if [file] is null/missing, or if decoding fails. Used for locally-stored icons/thumbnails + * (plugin icons, template thumbnails) where the file rarely changes, so a plain decode is enough + * and doesn't warrant an image-loading library dependency. + */ +@Composable +fun FileImage( + file: File?, + placeholder: Painter, + contentDescription: String?, + modifier: Modifier = Modifier, +) { + val bitmap by produceState(initialValue = null, file) { + value = + file + ?.takeIf { it.exists() } + ?.let { existing -> + withContext(Dispatchers.IO) { + runCatching { BitmapFactory.decodeFile(existing.absolutePath)?.asImageBitmap() }.getOrNull() + } + } + } + + val current = bitmap + if (current != null) { + Image( + bitmap = current, + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + } else { + Image( + painter = placeholder, + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Fit, + ) + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt new file mode 100644 index 0000000000..2d202bc693 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt @@ -0,0 +1,155 @@ +package com.itsaky.androidide.ui.compose.plugins + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.R +import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.ui.compose.common.FileImage +import com.itsaky.androidide.utils.isSystemInDarkMode +import java.io.File + +/** Matches the plugin list's version-chip truncation: `vX.Y.Z...` past three dot-segments. */ +internal fun pluginVersionLabel(version: String): String { + val segments = version.split('.') + return if (segments.size > 3) "v${segments.take(3).joinToString(".")}..." else "v$version" +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PluginListItem( + plugin: PluginInfo, + onEnable: () -> Unit, + onDisable: () -> Unit, + onUninstall: () -> Unit, + onDetails: () -> Unit, + onLongPressTooltip: () -> Unit, + modifier: Modifier = Modifier, +) { + var menuExpanded by remember { mutableStateOf(false) } + val context = LocalContext.current + + Card( + modifier = + modifier + .fillMaxWidth() + .combinedClickable(onClick = onDetails, onLongClick = onLongPressTooltip), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val iconPath = + if (context.isSystemInDarkMode()) { + plugin.metadata.iconNightPath + } else { + plugin.metadata.iconDayPath + } + FileImage( + file = iconPath?.let(::File), + placeholder = painterResource(R.drawable.ic_extension), + contentDescription = null, + modifier = Modifier.size(40.dp), + ) + + Spacer(Modifier.width(16.dp)) + + Column(modifier = Modifier.weight(1f)) { + Text(plugin.metadata.name, style = MaterialTheme.typography.titleMedium) + Text( + plugin.metadata.description, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row { + Text(pluginVersionLabel(plugin.metadata.version), style = MaterialTheme.typography.labelSmall) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.by_author, plugin.metadata.author), + style = MaterialTheme.typography.labelSmall, + ) + } + + val (statusText, statusColor) = + when { + !plugin.isLoaded -> stringResource(R.string.status_not_loaded) to colorResource(R.color.error) + !plugin.isEnabled -> stringResource(R.string.status_disabled) to colorResource(R.color.warning) + else -> stringResource(R.string.status_enabled) to colorResource(R.color.success) + } + Text(statusText, color = statusColor, style = MaterialTheme.typography.labelMedium) + } + + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = stringResource(R.string.cd_more_options), + ) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + if (plugin.isLoaded) { + if (plugin.isEnabled) { + DropdownMenuItem( + text = { Text(stringResource(R.string.disable_plugin)) }, + onClick = { + menuExpanded = false + onDisable() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.enable_plugin)) }, + onClick = { + menuExpanded = false + onEnable() + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.uninstall_plugin)) }, + onClick = { + menuExpanded = false + onUninstall() + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.plugin_details)) }, + onClick = { + menuExpanded = false + onDetails() + }, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt new file mode 100644 index 0000000000..7deb1f358a --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt @@ -0,0 +1,131 @@ +package com.itsaky.androidide.ui.compose.plugins + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Checkbox +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.itsaky.androidide.R +import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.plugins.PluginMetadata + +@Composable +fun InstallConfirmationDialog( + onConfirm: (deleteSourceAfterInstall: Boolean) -> Unit, + onDismiss: () -> Unit, +) { + var deleteSource by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_install_plugin)) }, + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = deleteSource, onCheckedChange = { deleteSource = it }) + Text(stringResource(R.string.checkbox_delete_source_after_install)) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(deleteSource) }) { Text(stringResource(R.string.btn_install)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +@Composable +fun OverwriteConfirmationDialog( + existing: PluginInfo, + incomingMetadata: PluginMetadata, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_plugin_already_installed)) }, + text = { + Text( + stringResource( + R.string.msg_plugin_overwrite_confirm, + existing.metadata.name, + existing.metadata.version, + incomingMetadata.version, + ), + ) + }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.replace)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +@Composable +fun UninstallConfirmationDialog( + plugin: PluginInfo, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_uninstall_plugin)) }, + text = { Text(stringResource(R.string.msg_uninstall_plugin_confirm, plugin.metadata.name)) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.uninstall_plugin)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +@Composable +fun PluginDetailsDialog( + plugin: PluginInfo, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(plugin.metadata.name) }, + text = { + SelectionContainer { + Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + DetailRow(stringResource(R.string.label_plugin_name), plugin.metadata.name) + DetailRow(stringResource(R.string.label_plugin_id), plugin.metadata.id) + DetailRow(stringResource(R.string.label_plugin_version), plugin.metadata.version) + DetailRow(stringResource(R.string.label_plugin_author), plugin.metadata.author) + DetailRow(stringResource(R.string.label_plugin_description), plugin.metadata.description) + DetailRow(stringResource(R.string.label_plugin_min_ide_version), plugin.metadata.minIdeVersion) + DetailRow(stringResource(R.string.plugin_permissions), plugin.metadata.permissions.joinToString(", ")) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.msg_ok)) } + }, + ) +} + +@Composable +private fun DetailRow( + label: String, + value: String, +) { + Text("$label: $value") +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerScreen.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerScreen.kt new file mode 100644 index 0000000000..b826f8dc69 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerScreen.kt @@ -0,0 +1,323 @@ +package com.itsaky.androidide.ui.compose.plugins + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.plugins.PluginMetadata +import com.itsaky.androidide.ui.models.PluginManagerUiEffect +import com.itsaky.androidide.ui.models.PluginManagerUiEvent +import com.itsaky.androidide.utils.DURATION_INDEFINITE +import com.itsaky.androidide.utils.DialogUtils +import com.itsaky.androidide.utils.UrlManager +import com.itsaky.androidide.utils.errorIcon +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.utils.flashbarBuilder +import com.itsaky.androidide.utils.getFileName +import com.itsaky.androidide.utils.showOnUiThread +import com.itsaky.androidide.viewmodels.PluginManagerViewModel + +private const val TAG = "PluginManagerScreen" +private const val PLUGIN_EXTENSION = ".cgp" + +private fun Uri.isSupportedPluginFile(activity: ComponentActivity): Boolean = + getFileName(activity).endsWith(PLUGIN_EXTENSION, ignoreCase = true) + +private sealed interface PluginManagerDialogState { + data object None : PluginManagerDialogState + + data class InstallConfirm( + val uri: Uri, + ) : PluginManagerDialogState + + data class OverwriteConfirm( + val existing: PluginInfo, + val incomingMetadata: PluginMetadata, + val uri: Uri, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerDialogState + + data class UninstallConfirm( + val plugin: PluginInfo, + ) : PluginManagerDialogState + + data class Details( + val plugin: PluginInfo, + ) : PluginManagerDialogState +} + +/** + * Compose port of the legacy `PluginManagerActivity`/`activity_plugin_manager.xml` screen (ADR 0009). + * Preserves every capability of the original: install (via SAF picker)/enable/disable/uninstall, + * overwrite/signature-mismatch conflict handling, restart prompt, and the discover-plugins action. + * + * The original wired the same long-press tooltip (`TooltipTag.PLUGIN_MANAGER`) to six separate + * views. Since they all show identical content, this collapses to two anchor points here: each + * list item (already handles its own tap-for-details gesture) and the screen's background/empty + * state area - long-pressing anywhere else on the screen shows the same tooltip. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun PluginManagerScreen( + activity: ComponentActivity, + viewModel: PluginManagerViewModel, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var dialogState by remember { mutableStateOf(PluginManagerDialogState.None) } + val rootView = LocalView.current + + fun showTooltip() { + TooltipManager.showIdeCategoryTooltip(activity, rootView, TooltipTag.PLUGIN_MANAGER) + } + + val filePickerLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> + uri?.let { + try { + activity.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (e: SecurityException) { + Log.w(TAG, "Could not take persistable URI permission", e) + } + + if (!it.isSupportedPluginFile(activity)) { + activity.flashError(activity.getString(R.string.msg_unsupported_plugin_file)) + } else { + dialogState = PluginManagerDialogState.InstallConfirm(it) + } + } + } + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is PluginManagerUiEffect.ShowError -> { + val message = activity.getString(effect.messageResId, *effect.formatArgs.toTypedArray()) + val builder = + activity + .flashbarBuilder(duration = if (effect.formatArgs.isEmpty()) 5000L else DURATION_INDEFINITE) + .errorIcon() + .message(message) + if (effect.formatArgs.isNotEmpty()) { + builder + .positiveActionText(R.string.copy) + .positiveActionTapListener { bar -> + activity + .getSystemService(ClipboardManager::class.java) + ?.setPrimaryClip( + ClipData.newPlainText(activity.getString(R.string.msg_plugin_error_clip_label), message), + ) + bar.dismiss() + } + } + builder.showOnUiThread() + } + + is PluginManagerUiEffect.ShowSuccess -> { + activity.flashSuccess(activity.getString(effect.messageResId)) + } + + is PluginManagerUiEffect.ShowPluginDetails -> { + dialogState = PluginManagerDialogState.Details(effect.plugin) + } + + is PluginManagerUiEffect.OpenFilePicker -> { + try { + filePickerLauncher.launch(arrayOf("*/*")) + } catch (_: Exception) { + activity.flashError(activity.getString(R.string.msg_no_file_manager)) + } + } + + is PluginManagerUiEffect.ShowUninstallConfirmation -> { + dialogState = PluginManagerDialogState.UninstallConfirm(effect.plugin) + } + + is PluginManagerUiEffect.ShowRestartPrompt -> { + DialogUtils.showRestartPrompt(activity) + } + + is PluginManagerUiEffect.ShowOverwriteConfirmation -> { + dialogState = + PluginManagerDialogState.OverwriteConfirm( + existing = effect.existing, + incomingMetadata = effect.incomingMetadata, + uri = effect.uri, + deleteSourceAfterInstall = effect.deleteSourceAfterInstall, + ) + } + } + } + } + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_plugin_manager)) }, + navigationIcon = { + IconButton(onClick = { activity.onBackPressedDispatcher.onBackPressed() }) { + Icon( + painter = painterResource(R.drawable.ic_back), + contentDescription = stringResource(android.R.string.cancel), + ) + } + }, + actions = { + IconButton( + onClick = { + UrlManager.openUrl(activity.getString(R.string.url_discover_plugins), null, activity) + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_download), + contentDescription = stringResource(R.string.action_discover_plugins), + ) + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { viewModel.onEvent(PluginManagerUiEvent.OpenFilePicker) }, + ) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = stringResource(R.string.cd_add), + ) + } + }, + ) { padding -> + Box( + modifier = + Modifier + .padding(padding) + .fillMaxSize() + .pointerInput(Unit) { detectTapGestures(onLongPress = { showTooltip() }) }, + ) { + if (uiState.showEmptyState) { + PluginManagerEmptyState(modifier = Modifier.fillMaxSize()) + } else { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { + items(uiState.plugins, key = { it.metadata.id }) { plugin -> + PluginListItem( + plugin = plugin, + onEnable = { viewModel.onEvent(PluginManagerUiEvent.EnablePlugin(plugin.metadata.id)) }, + onDisable = { viewModel.onEvent(PluginManagerUiEvent.DisablePlugin(plugin.metadata.id)) }, + onUninstall = { viewModel.onEvent(PluginManagerUiEvent.UninstallPlugin(plugin.metadata.id)) }, + onDetails = { viewModel.onEvent(PluginManagerUiEvent.ShowPluginDetails(plugin)) }, + onLongPressTooltip = { showTooltip() }, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } + } + } + } + + when (val dialog = dialogState) { + is PluginManagerDialogState.None -> {} + + is PluginManagerDialogState.InstallConfirm -> { + InstallConfirmationDialog( + onConfirm = { deleteSource -> + viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(dialog.uri, deleteSource)) + dialogState = PluginManagerDialogState.None + }, + onDismiss = { dialogState = PluginManagerDialogState.None }, + ) + } + + is PluginManagerDialogState.OverwriteConfirm -> { + OverwriteConfirmationDialog( + existing = dialog.existing, + incomingMetadata = dialog.incomingMetadata, + onConfirm = { + viewModel.onEvent(PluginManagerUiEvent.ConfirmOverwrite(dialog.uri, dialog.deleteSourceAfterInstall)) + dialogState = PluginManagerDialogState.None + }, + onDismiss = { dialogState = PluginManagerDialogState.None }, + ) + } + + is PluginManagerDialogState.UninstallConfirm -> { + UninstallConfirmationDialog( + plugin = dialog.plugin, + onConfirm = { + viewModel.confirmUninstallPlugin(dialog.plugin.metadata.id) + dialogState = PluginManagerDialogState.None + }, + onDismiss = { dialogState = PluginManagerDialogState.None }, + ) + } + + is PluginManagerDialogState.Details -> { + PluginDetailsDialog( + plugin = dialog.plugin, + onDismiss = { dialogState = PluginManagerDialogState.None }, + ) + } + } +} + +@Composable +private fun PluginManagerEmptyState(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(R.drawable.ic_package), + contentDescription = null, + modifier = + Modifier + .size(64.dp) + .padding(bottom = 16.dp), + ) + Text(stringResource(R.string.no_plugins_installed), style = MaterialTheme.typography.headlineSmall) + Text( + stringResource(R.string.no_plugins_installed_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/res/layout/activity_plugin_manager.xml b/app/src/main/res/layout/activity_plugin_manager.xml index 120e4dd2e1..70204d9e6f 100644 --- a/app/src/main/res/layout/activity_plugin_manager.xml +++ b/app/src/main/res/layout/activity_plugin_manager.xml @@ -1,83 +1,16 @@ - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="match_parent" /> - + diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index c56ca9ac40..213d17f366 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -910,6 +910,10 @@ Plugin Details Permissions Dependencies + by %1$s + Not Loaded + Disabled + Enabled Plugin crashed @@ -1075,6 +1079,14 @@ Delete installation file after install Discover plugins https://www.appdevforall.org/contribute/ + Uninstall Plugin + Are you sure you want to uninstall \'%1$s\'? + Name + Plugin ID + Version + Author + Description + Min IDE Version %1$s: %2$s \n\nProject creation finished with warnings/errors. Open IDE Logs for details. From 7b6f8b4f686cc6183a9d1b308d44d1aa60f54050 Mon Sep 17 00:00:00 2001 From: yaturner Date: Thu, 30 Jul 2026 07:06:26 -0700 Subject: [PATCH 03/16] ADFA-4928: Add Templates data layer Ports the parsing/model layer from appdevforall/TemplateManagerPlugin (CgtTemplateReader, TemplateMetadata/CgtFileItem, plus their unit tests) into the app module as the basis for the new Templates tab. Adds TemplateRepository/TemplateRepositoryImpl, which reimplement the plugin's install/uninstall/delete semantics as direct file operations on Environment.TEMPLATES_DIR + the Downloads folder, since the host app doesn't need IdeTemplateService's plugin-facing permission gate. Provenance (bundled/plugin/user) is inferred from the same filename convention IdeTemplateServiceImpl/PluginProjectManager already use. Adds TemplateManagerViewModel (UDF shape matching PluginManagerViewModel) and a Koin di/TemplateModule, registered in IDEApplication alongside pluginModule. No UI yet - this commit is data-layer only. CgtTemplateReaderTest needs @RunWith(RobolectricTestRunner::class): org.json.JSONObject throws "not mocked" under a plain JVM unit test, same as other app-module tests that touch real android.jar classes. --- .../itsaky/androidide/app/IDEApplication.kt | 3 +- .../itsaky/androidide/di/TemplateModule.kt | 30 ++++ .../repositories/TemplateRepository.kt | 28 ++++ .../repositories/TemplateRepositoryImpl.kt | 114 +++++++++++++ .../templates/manager/models/CgtFileItem.kt | 59 +++++++ .../manager/parsing/CgtTemplateReader.kt | 61 +++++++ .../ui/models/TemplateManagerUiState.kt | 77 +++++++++ .../viewmodels/TemplateManagerViewModel.kt | 151 ++++++++++++++++++ .../manager/models/CgtFileItemTest.kt | 75 +++++++++ .../manager/parsing/CgtTemplateReaderTest.kt | 121 ++++++++++++++ resources/src/main/res/values/strings.xml | 8 + 11 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt create mode 100644 app/src/main/java/com/itsaky/androidide/repositories/TemplateRepository.kt create mode 100644 app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt create mode 100644 app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt create mode 100644 app/src/main/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReader.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt create mode 100644 app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt create mode 100644 app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index a4364353cb..f1eeb34a7b 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -29,6 +29,7 @@ import androidx.work.Configuration import com.itsaky.androidide.BuildConfig import com.itsaky.androidide.di.coreModule import com.itsaky.androidide.di.pluginModule +import com.itsaky.androidide.di.templateModule import com.itsaky.androidide.handlers.GlitchTipDiagnosticsContext import com.itsaky.androidide.plugins.manager.core.PluginManager import com.itsaky.androidide.treesitter.TreeSitter @@ -208,7 +209,7 @@ class IDEApplication : runCatching { GlobalContext.get() }.getOrNull()?.let { return } startKoin { androidContext(this@IDEApplication) - modules(coreModule, pluginModule) + modules(coreModule, pluginModule, templateModule) } } diff --git a/app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt b/app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt new file mode 100644 index 0000000000..efddd25b84 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/di/TemplateModule.kt @@ -0,0 +1,30 @@ +package com.itsaky.androidide.di + +import com.itsaky.androidide.repositories.TemplateRepository +import com.itsaky.androidide.repositories.TemplateRepositoryImpl +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel +import org.koin.androidx.viewmodel.dsl.viewModel +import org.koin.dsl.module + +/** + * Koin module for template-related dependencies + */ +val templateModule = + module { + + // Repository + single { + TemplateRepositoryImpl( + templatesDir = Environment.TEMPLATES_DIR, + downloadDir = Environment.DOWNLOAD_DIR, + ) + } + + // ViewModel + viewModel { + TemplateManagerViewModel( + templateRepository = get(), + ) + } + } diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepository.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepository.kt new file mode 100644 index 0000000000..ee497b0106 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepository.kt @@ -0,0 +1,28 @@ +package com.itsaky.androidide.repositories + +import com.itsaky.androidide.templates.manager.models.CgtFileItem + +/** + * Repository interface for template (`.cgt`) file operations. + * + * Unlike [PluginRepository], this talks directly to the filesystem + * (`Environment.TEMPLATES_DIR` + the Downloads folder) rather than through a plugin-facing + * service - the host app doesn't need the `pluginId`/permission indirection that + * `IdeTemplateService` exists for. + */ +interface TemplateRepository { + /** + * Scans `Environment.TEMPLATES_DIR` (installed) and the Downloads folder (not installed) + * for `.cgt` files and parses each into a [CgtFileItem]. + */ + suspend fun listTemplateFiles(): Result> + + /** Moves [item]'s file from Downloads into the templates directory and reloads templates. */ + suspend fun installTemplate(item: CgtFileItem): Result + + /** Restores a copy of [item]'s file to Downloads, removes it from the templates directory, and reloads templates. */ + suspend fun uninstallTemplate(item: CgtFileItem): Result + + /** Deletes a not-installed [item]'s file from Downloads. */ + suspend fun deleteDownloadFile(item: CgtFileItem): Result +} diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt new file mode 100644 index 0000000000..f718c755ec --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt @@ -0,0 +1,114 @@ +package com.itsaky.androidide.repositories + +import android.util.Log +import com.itsaky.androidide.templates.ITemplateProvider +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.templates.manager.models.TemplateProvenance +import com.itsaky.androidide.templates.manager.parsing.CgtTemplateReader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.adfa.constants.TEMPLATE_CORE_ARCHIVE +import java.io.File +import java.io.IOException + +/** + * Implementation of [TemplateRepository]. + * + * Reimplements the install/uninstall/delete semantics of the reference + * `TemplateManagerPlugin` fragment as direct file operations, since the host app already has + * unrestricted access to [templatesDir]/[downloadDir] and doesn't need `IdeTemplateService`'s + * plugin-facing permission gate. + */ +class TemplateRepositoryImpl( + private val templatesDir: File, + private val downloadDir: File, +) : TemplateRepository { + private companion object { + private const val TAG = "TemplateRepository" + private const val CGT_EXTENSION = "cgt" + private const val PLUGIN_CGT_PREFIX = "plugin_" + } + + override suspend fun listTemplateFiles(): Result> = + withContext(Dispatchers.IO) { + runCatching { scanTemplates() } + .onFailure { exception -> Log.e(TAG, "Failed to scan template files", exception) } + } + + private fun scanTemplates(): List { + val installed = cgtFilesIn(templatesDir).map { file -> parseCgtFile(file, installed = true) } + val downloaded = cgtFilesIn(downloadDir).map { file -> parseCgtFile(file, installed = false) } + return (installed + downloaded).filterNotNull() + } + + private fun cgtFilesIn(dir: File): List = + dir + .listFiles { file -> file.isFile && file.extension.equals(CGT_EXTENSION, ignoreCase = true) } + ?.sortedBy { it.name } + ?: emptyList() + + /** Parses a .cgt (which may bundle multiple templates) into a card item, or null if it contains no template.json. */ + private fun parseCgtFile( + file: File, + installed: Boolean, + ): CgtFileItem? { + val templates = + runCatching { file.inputStream().use(CgtTemplateReader::readTemplates) } + .onFailure { exception -> Log.w(TAG, "Failed to parse ${file.absolutePath}", exception) } + .getOrNull() + ?: return null + if (templates.isEmpty()) return null + return CgtFileItem( + file = file, + name = file.name, + templates = templates, + installed = installed, + provenance = provenanceOf(file.name), + ) + } + + private fun provenanceOf(fileName: String): TemplateProvenance = + when { + fileName == TEMPLATE_CORE_ARCHIVE -> TemplateProvenance.BUNDLED + fileName.startsWith(PLUGIN_CGT_PREFIX) -> TemplateProvenance.PLUGIN + else -> TemplateProvenance.USER + } + + override suspend fun installTemplate(item: CgtFileItem): Result = + withContext(Dispatchers.IO) { + runCatching { + check(!item.installed) { "'${item.name}' is already installed" } + val dest = File(templatesDir, item.file.name) + item.file.copyTo(dest, overwrite = true) + item.file.delete() + ITemplateProvider.getInstance(reload = true) + }.onFailure { exception -> Log.e(TAG, "Failed to install template: ${item.name}", exception) } + .map {} + } + + override suspend fun uninstallTemplate(item: CgtFileItem): Result = + withContext(Dispatchers.IO) { + runCatching { + check(item.installed) { "'${item.name}' is not installed" } + check(item.provenance != TemplateProvenance.BUNDLED) { "Cannot uninstall the bundled template" } + + // Restore a copy to Downloads BEFORE removing it from the store: if the restore + // throws, the store copy below is never touched, so the user's only copy survives. + val restored = File(downloadDir, item.file.name) + item.file.copyTo(restored, overwrite = true) + item.file.delete() + ITemplateProvider.getInstance(reload = true) + }.onFailure { exception -> Log.e(TAG, "Failed to uninstall template: ${item.name}", exception) } + .map {} + } + + override suspend fun deleteDownloadFile(item: CgtFileItem): Result = + withContext(Dispatchers.IO) { + runCatching { + check(!item.installed) { "Cannot delete an installed template; uninstall it first" } + if (!item.file.delete()) { + throw IOException("Failed to delete ${item.file.absolutePath}") + } + }.onFailure { exception -> Log.e(TAG, "Failed to delete download file: ${item.name}", exception) } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt b/app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt new file mode 100644 index 0000000000..84b293ca53 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt @@ -0,0 +1,59 @@ +package com.itsaky.androidide.templates.manager.models + +import java.io.File + +data class TemplateMetadata( + val name: String, + val description: String, + val version: String, + /** Tags declared under parameters.optional in template.json, e.g. "language (LANGUAGE)". */ + val optionalTags: List = emptyList(), +) + +/** + * Where a `.cgt` file came from, inferred from its filename convention (there is no stable + * template ID: [com.itsaky.androidide.templates.Template.templateId] is a random UUID + * regenerated on every reload). Matches the convention used by + * `IdeTemplateServiceImpl`/`PluginProjectManager` when they write into `Environment.TEMPLATES_DIR`. + */ +enum class TemplateProvenance { + /** The IDE's bundled `core.cgt`. */ + BUNDLED, + + /** Registered by a plugin (`plugin__*.cgt`). */ + PLUGIN, + + /** Anything else - user-imported via this screen or manually copied in. */ + USER, +} + +data class CgtFileItem( + val file: File, + val name: String, + val templates: List, + val installed: Boolean, + val provenance: TemplateProvenance, +) + +/** The first template's metadata, used to populate the card's title/description/version. */ +val CgtFileItem.primaryTemplate: TemplateMetadata + get() = templates.firstOrNull() ?: TemplateMetadata(name = "", description = "", version = "") + +/** True when this .cgt file bundles more than one template. */ +val CgtFileItem.hasMultipleTemplates: Boolean + get() = templates.size > 1 + +/** [CgtFileItem.name] without the redundant ".cgt" extension, for display only. */ +val CgtFileItem.displayName: String + get() = if (name.endsWith(".cgt", ignoreCase = true)) name.dropLast(4) else name + +/** + * Formats a version for the card's version chip, matching the host Plugin Manager: + * a "v" prefix, and versions with more than three dot-segments truncated to the first + * three plus an ellipsis. Blank versions render as an empty string. + */ +fun versionLabel(version: String): String { + if (version.isBlank()) return "" + val segments = version.split('.') + return if (segments.size > 3) "v${segments.take(3).joinToString(".")}..." else "v$version" +} diff --git a/app/src/main/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReader.kt b/app/src/main/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReader.kt new file mode 100644 index 0000000000..549312b23d --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReader.kt @@ -0,0 +1,61 @@ +package com.itsaky.androidide.templates.manager.parsing + +import com.itsaky.androidide.templates.manager.models.TemplateMetadata +import org.json.JSONObject +import java.io.InputStream +import java.util.zip.ZipInputStream + +/** + * Pure parser for Code On The Go template (`.cgt`) archives. A `.cgt` is a zip that may + * bundle one or more templates, each described by a `/template/template.json` entry. + * + * Kept free of Android/IDE dependencies so it can be unit-tested directly. + */ +object CgtTemplateReader { + private const val TEMPLATE_JSON_SUFFIX = "/template/template.json" + + /** + * Reads every `/template/template.json` entry from a `.cgt` zip [input] and returns + * one [TemplateMetadata] per entry (empty if the archive contains none). The stream is + * consumed and closed. + */ + fun readTemplates(input: InputStream): List { + val templates = mutableListOf() + ZipInputStream(input).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + if (!entry.isDirectory && entry.name.endsWith(TEMPLATE_JSON_SUFFIX)) { + val json = JSONObject(zip.readBytes().toString(Charsets.UTF_8)) + templates.add( + TemplateMetadata( + name = json.optString("name"), + description = json.optString("description"), + version = json.optString("version"), + optionalTags = parseOptionalTags(json), + ), + ) + } + zip.closeEntry() + } + } + return templates + } + + /** + * Collects the tags declared under `parameters.optional`, each rendered as + * " ()" when the entry carries an identifier, else just "". + */ + fun parseOptionalTags(json: JSONObject): List { + val optional = + json.optJSONObject("parameters")?.optJSONObject("optional") + ?: return emptyList() + val tags = mutableListOf() + val keys = optional.keys() + while (keys.hasNext()) { + val key = keys.next() + val identifier = optional.optJSONObject(key)?.optString("identifier").orEmpty() + tags.add(if (identifier.isNotBlank()) "$key ($identifier)" else key) + } + return tags + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt b/app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt new file mode 100644 index 0000000000..a5f1f45dbe --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/models/TemplateManagerUiState.kt @@ -0,0 +1,77 @@ +package com.itsaky.androidide.ui.models + +import androidx.annotation.StringRes +import com.itsaky.androidide.templates.manager.models.CgtFileItem + +data class TemplateManagerUiState( + val isLoading: Boolean = false, + val items: List = emptyList(), +) { + val isEmpty: Boolean + get() = items.isEmpty() && !isLoading +} + +sealed class TemplateManagerUiEvent { + object LoadTemplates : TemplateManagerUiEvent() + + data class InstallTemplate( + val item: CgtFileItem, + ) : TemplateManagerUiEvent() + + data class UninstallTemplate( + val item: CgtFileItem, + ) : TemplateManagerUiEvent() + + data class DeleteDownloadFile( + val item: CgtFileItem, + ) : TemplateManagerUiEvent() + + data class ShowTemplateDetails( + val item: CgtFileItem, + ) : TemplateManagerUiEvent() + + data class ShowTemplateList( + val item: CgtFileItem, + ) : TemplateManagerUiEvent() +} + +sealed class TemplateManagerUiEffect { + data class ShowError( + @StringRes val messageResId: Int, + val formatArgs: List = emptyList(), + ) : TemplateManagerUiEffect() + + data class ShowSuccess( + @StringRes val messageResId: Int, + ) : TemplateManagerUiEffect() + + data class ShowDeleteConfirmation( + val item: CgtFileItem, + ) : TemplateManagerUiEffect() + + data class ShowTemplateDetails( + val item: CgtFileItem, + ) : TemplateManagerUiEffect() + + data class ShowTemplateList( + val item: CgtFileItem, + ) : TemplateManagerUiEffect() +} + +sealed class TemplateOperation { + object None : TemplateOperation() + + object Loading : TemplateOperation() + + data class Installing( + val file: java.io.File, + ) : TemplateOperation() + + data class Uninstalling( + val file: java.io.File, + ) : TemplateOperation() + + data class Deleting( + val file: java.io.File, + ) : TemplateOperation() +} diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt new file mode 100644 index 0000000000..76b6a7a207 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt @@ -0,0 +1,151 @@ +package com.itsaky.androidide.viewmodels + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.itsaky.androidide.repositories.TemplateRepository +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.ui.models.TemplateManagerUiEffect +import com.itsaky.androidide.ui.models.TemplateManagerUiEvent +import com.itsaky.androidide.ui.models.TemplateManagerUiState +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * ViewModel for the Templates tab. Same UDF shape as [PluginManagerViewModel]. + */ +class TemplateManagerViewModel( + private val templateRepository: TemplateRepository, +) : ViewModel() { + private companion object { + private const val TAG = "TemplateManagerViewModel" + } + + private val _uiState = MutableStateFlow(TemplateManagerUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val _uiEffect = Channel() + val uiEffect = _uiEffect.receiveAsFlow() + + init { + loadTemplates() + } + + fun onEvent(event: TemplateManagerUiEvent) { + when (event) { + is TemplateManagerUiEvent.LoadTemplates -> loadTemplates() + is TemplateManagerUiEvent.InstallTemplate -> installTemplate(event.item) + is TemplateManagerUiEvent.UninstallTemplate -> uninstallTemplate(event.item) + is TemplateManagerUiEvent.DeleteDownloadFile -> showDeleteConfirmation(event.item) + is TemplateManagerUiEvent.ShowTemplateDetails -> showTemplateDetails(event.item) + is TemplateManagerUiEvent.ShowTemplateList -> showTemplateList(event.item) + } + } + + private fun loadTemplates() { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + + templateRepository + .listTemplateFiles() + .onSuccess { items -> + Log.d(TAG, "Loaded ${items.size} template files") + _uiState.update { it.copy(isLoading = false, items = items) } + }.onFailure { exception -> + Log.e(TAG, "Failed to load template files", exception) + _uiState.update { it.copy(isLoading = false) } + _uiEffect.trySend( + TemplateManagerUiEffect.ShowError( + R.string.msg_template_load_failed, + listOf(exception.message ?: ""), + ), + ) + } + } + } + + private fun installTemplate(item: CgtFileItem) { + viewModelScope.launch { + templateRepository + .installTemplate(item) + .onSuccess { + Log.d(TAG, "Template installed successfully: ${item.name}") + _uiEffect.trySend(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_installed)) + loadTemplates() + }.onFailure { exception -> + Log.e(TAG, "Failed to install template: ${item.name}", exception) + _uiEffect.trySend( + TemplateManagerUiEffect.ShowError( + R.string.msg_template_install_failed, + listOf(exception.message ?: ""), + ), + ) + } + } + } + + private fun uninstallTemplate(item: CgtFileItem) { + viewModelScope.launch { + templateRepository + .uninstallTemplate(item) + .onSuccess { + Log.d(TAG, "Template uninstalled successfully: ${item.name}") + _uiEffect.trySend(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_uninstalled)) + loadTemplates() + }.onFailure { exception -> + Log.e(TAG, "Failed to uninstall template: ${item.name}", exception) + _uiEffect.trySend( + TemplateManagerUiEffect.ShowError( + R.string.msg_template_uninstall_failed, + listOf(exception.message ?: ""), + ), + ) + } + } + } + + private fun showDeleteConfirmation(item: CgtFileItem) { + viewModelScope.launch { + _uiEffect.trySend(TemplateManagerUiEffect.ShowDeleteConfirmation(item)) + } + } + + /** Deletes a not-installed template's Downloads file (called after confirmation). */ + fun confirmDeleteDownloadFile(item: CgtFileItem) { + viewModelScope.launch { + templateRepository + .deleteDownloadFile(item) + .onSuccess { + Log.d(TAG, "Deleted download file: ${item.name}") + _uiEffect.trySend(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_deleted)) + loadTemplates() + }.onFailure { exception -> + Log.e(TAG, "Failed to delete download file: ${item.name}", exception) + _uiEffect.trySend( + TemplateManagerUiEffect.ShowError( + R.string.msg_template_delete_failed, + listOf(exception.message ?: ""), + ), + ) + } + } + } + + private fun showTemplateDetails(item: CgtFileItem) { + viewModelScope.launch { + _uiEffect.trySend(TemplateManagerUiEffect.ShowTemplateDetails(item)) + } + } + + private fun showTemplateList(item: CgtFileItem) { + viewModelScope.launch { + _uiEffect.trySend(TemplateManagerUiEffect.ShowTemplateList(item)) + } + } +} diff --git a/app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt b/app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt new file mode 100644 index 0000000000..1345f06717 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt @@ -0,0 +1,75 @@ +package com.itsaky.androidide.templates.manager.models + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class CgtFileItemTest { + private fun item( + name: String, + templates: List = listOf(TemplateMetadata("T", "d", "1.0")), + provenance: TemplateProvenance = TemplateProvenance.USER, + ) = CgtFileItem( + file = File("/tmp/$name"), + name = name, + templates = templates, + installed = false, + provenance = provenance, + ) + + @Test + fun displayName_stripsCgtExtension() { + assertEquals("core", item("core.cgt").displayName) + assertEquals("core", item("core.CGT").displayName) // case-insensitive + } + + @Test + fun displayName_leavesOtherNamesUnchanged() { + assertEquals("core", item("core").displayName) + assertEquals("my.template.cgt".dropLast(4), item("my.template.cgt").displayName) + assertEquals("readme.txt", item("readme.txt").displayName) + } + + @Test + fun primaryTemplate_isFirst_orEmptyFallback() { + val a = TemplateMetadata("A", "da", "1.0") + val b = TemplateMetadata("B", "db", "2.0") + assertEquals(a, item("x.cgt", listOf(a, b)).primaryTemplate) + + val empty = item("x.cgt", emptyList()).primaryTemplate + assertEquals("", empty.name) + assertEquals("", empty.version) + } + + @Test + fun hasMultipleTemplates_reflectsCount() { + assertFalse(item("x.cgt", listOf(TemplateMetadata("A", "", "1"))).hasMultipleTemplates) + assertTrue( + item("x.cgt", listOf(TemplateMetadata("A", "", "1"), TemplateMetadata("B", "", "1"))) + .hasMultipleTemplates, + ) + assertFalse(item("x.cgt", emptyList()).hasMultipleTemplates) + } + + @Test + fun versionLabel_prefixesWithV() { + assertEquals("v1.0", versionLabel("1.0")) + assertEquals("v0.1", versionLabel("0.1")) + assertEquals("v1.2.3", versionLabel("1.2.3")) + } + + @Test + fun versionLabel_truncatesMoreThanThreeSegments() { + // Only the first three dot-separated segments are kept (matches the host Plugin Manager). + assertEquals("v1.0.0-build...", versionLabel("1.0.0-build.20260101")) + assertEquals("v1.2.3...", versionLabel("1.2.3.4")) + } + + @Test + fun versionLabel_blankBecomesEmpty() { + assertEquals("", versionLabel("")) + assertEquals("", versionLabel(" ")) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt b/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt new file mode 100644 index 0000000000..3b4ca446f3 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt @@ -0,0 +1,121 @@ +package com.itsaky.androidide.templates.manager.parsing + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +// org.json.JSONObject needs Robolectric's shadow to run real logic instead of +// android.jar's "not mocked" stub. +@RunWith(RobolectricTestRunner::class) +class CgtTemplateReaderTest { + /** Builds an in-memory .cgt (zip) from a map of entry path -> contents. */ + private fun cgt(entries: Map): ByteArrayInputStream { + val bytes = ByteArrayOutputStream() + ZipOutputStream(bytes).use { zip -> + for ((path, content) in entries) { + zip.putNextEntry(ZipEntry(path)) + zip.write(content.toByteArray(Charsets.UTF_8)) + zip.closeEntry() + } + } + return ByteArrayInputStream(bytes.toByteArray()) + } + + @Test + fun readsSingleTemplateMetadata() { + val input = + cgt( + mapOf( + "pkg/template/template.json" to + """{"name":"Basic Activity","description":"Creates a new basic activity","version":"0.1"}""", + ), + ) + val result = CgtTemplateReader.readTemplates(input) + assertEquals(1, result.size) + assertEquals("Basic Activity", result[0].name) + assertEquals("Creates a new basic activity", result[0].description) + assertEquals("0.1", result[0].version) + assertTrue(result[0].optionalTags.isEmpty()) + } + + @Test + fun readsAllTemplatesInMultiTemplateArchive() { + val input = + cgt( + mapOf( + "a/template/template.json" to """{"name":"Empty","description":"e","version":"1.0"}""", + "b/template/template.json" to """{"name":"Login","description":"l","version":"1.1"}""", + "a/build.gradle.kts.peb" to "// not a template.json", + ), + ) + val result = CgtTemplateReader.readTemplates(input) + assertEquals(2, result.size) + assertEquals(setOf("Empty", "Login"), result.map { it.name }.toSet()) + } + + @Test + fun parsesOptionalParametersAsTagWithIdentifier() { + val input = + cgt( + mapOf( + "pkg/template/template.json" to + """ + { + "name":"T","description":"d","version":"1.0", + "parameters": { "optional": { + "language": {"identifier":"LANGUAGE"}, + "minsdk": {"identifier":"MIN_SDK"} + } } + } + """.trimIndent(), + ), + ) + val tags = CgtTemplateReader.readTemplates(input).single().optionalTags + // org.json key iteration order isn't guaranteed, so compare as a set. + assertEquals(setOf("language (LANGUAGE)", "minsdk (MIN_SDK)"), tags.toSet()) + } + + @Test + fun handlesUnquotedInnerKeys_asShippedByCore() { + // The bundled core.cgt uses lenient JSON with unquoted inner keys; org.json accepts it. + val input = + cgt( + mapOf( + "BasicActivity/template/template.json" to + """ + { + "name":"Basic Activity","description":"d","version":"0.1", + "parameters": { "optional": { "language": {identifier: "LANGUAGE"} } } + } + """.trimIndent(), + ), + ) + val template = CgtTemplateReader.readTemplates(input).single() + assertEquals("Basic Activity", template.name) + assertEquals(listOf("language (LANGUAGE)"), template.optionalTags) + } + + @Test + fun optionalTagWithoutIdentifierFallsBackToKey() { + val input = + cgt( + mapOf( + "pkg/template/template.json" to + """{"name":"T","description":"d","version":"1.0","parameters":{"optional":{"flag":{}}}}""", + ), + ) + assertEquals(listOf("flag"), CgtTemplateReader.readTemplates(input).single().optionalTags) + } + + @Test + fun returnsEmptyWhenNoTemplateJson() { + val input = cgt(mapOf("pkg/readme.txt" to "hello", "pkg/template/other.json" to "{}")) + assertTrue(CgtTemplateReader.readTemplates(input).isEmpty()) + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 213d17f366..16283533fc 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1089,6 +1089,14 @@ Min IDE Version %1$s: %2$s + Failed to load templates: %1$s + Template installed successfully + Failed to install template: %1$s + Template uninstalled successfully + Failed to uninstall template: %1$s + Deleted from Downloads + Failed to delete template: %1$s + \n\nProject creation finished with warnings/errors. Open IDE Logs for details. From a62c00805bfe2eb387a7de7dbeb77e21792116b8 Mon Sep 17 00:00:00 2001 From: yaturner Date: Thu, 30 Jul 2026 07:12:56 -0700 Subject: [PATCH 04/16] ADFA-4928: Add Templates tab Compose UI Adds the Compose UI for the Templates tab, backed by the data layer from the previous commit: TemplateListItem (card - tapping only opens the multi-template sub-list, matching the reference plugin's design), TemplateManagerDialogs (delete confirmation, file-level details, per-template details, multi-template sub-list), and TemplateManagerScreen (content composable wiring the ViewModel's uiState/uiEffect, same long-press pointerInput tooltip shim as the Plugins tab, new TooltipTag.TEMPLATE_MANAGER). TemplateManagerScreen is content-only (no Scaffold/TopAppBar/FAB) - unlike the Plugins tab there's no install-flow FAB, matching the ported plugin's passive Downloads-folder scanning. It's meant to be composed as one tab's body inside the shared manager screen; wiring the two tabs together is the next commit. --- .../ui/compose/templates/TemplateListItem.kt | 172 ++++++++++++++ .../templates/TemplateManagerDialogs.kt | 165 ++++++++++++++ .../templates/TemplateManagerScreen.kt | 211 ++++++++++++++++++ .../androidide/idetooltips/TooltipTag.kt | 1 + resources/src/main/res/values/strings.xml | 28 +++ 5 files changed, 577 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerDialogs.kt create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerScreen.kt diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt new file mode 100644 index 0000000000..1688063e08 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt @@ -0,0 +1,172 @@ +package com.itsaky.androidide.ui.compose.templates + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.R +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.templates.manager.models.TemplateProvenance +import com.itsaky.androidide.templates.manager.models.displayName +import com.itsaky.androidide.templates.manager.models.hasMultipleTemplates +import com.itsaky.androidide.templates.manager.models.primaryTemplate +import com.itsaky.androidide.templates.manager.models.versionLabel + +/** + * Card for a single `.cgt` file. Matches the reference plugin's card: tapping the card only + * opens the multi-template sub-list when the file bundles more than one template; single-template + * files are only actionable through the overflow menu. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TemplateListItem( + item: CgtFileItem, + onInstall: () -> Unit, + onUninstall: () -> Unit, + onDetails: () -> Unit, + onDelete: () -> Unit, + onViewTemplates: () -> Unit, + onLongPressTooltip: () -> Unit, + modifier: Modifier = Modifier, +) { + var menuExpanded by remember { mutableStateOf(false) } + val primary = item.primaryTemplate + + Card( + modifier = + modifier + .fillMaxWidth() + .combinedClickable( + onClick = { if (item.hasMultipleTemplates) onViewTemplates() }, + onLongClick = onLongPressTooltip, + ), + ) { + Row(modifier = Modifier.padding(16.dp)) { + Column(modifier = Modifier.weight(1f)) { + Text(primary.name.ifBlank { item.displayName }, style = MaterialTheme.typography.titleMedium) + Text( + primary.description, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + + val versionText = versionLabel(primary.version) + if (versionText.isNotBlank()) { + Text(versionText, style = MaterialTheme.typography.labelSmall) + } + Text(item.displayName, style = MaterialTheme.typography.labelSmall) + + if (item.hasMultipleTemplates) { + Text( + stringResource(R.string.template_contains_count, item.templates.size), + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.clickable(onClick = onViewTemplates), + ) + } + + Row { + val (statusText, statusColor) = + if (item.installed) { + stringResource(R.string.status_template_installed) to colorResource(R.color.success) + } else { + stringResource(R.string.status_template_not_installed) to colorResource(R.color.error) + } + Text(statusText, color = statusColor, style = MaterialTheme.typography.labelMedium) + Text( + " - " + stringResource(item.provenance.labelRes()), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Box { + IconButton(onClick = { menuExpanded = true }) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = stringResource(R.string.cd_more_options), + ) + } + DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) { + if (item.installed) { + if (item.provenance != TemplateProvenance.BUNDLED) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_uninstall_template)) }, + onClick = { + menuExpanded = false + onUninstall() + }, + ) + } + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_install_template)) }, + onClick = { + menuExpanded = false + onInstall() + }, + ) + } + + if (item.hasMultipleTemplates) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_view_templates)) }, + onClick = { + menuExpanded = false + onViewTemplates() + }, + ) + } else { + DropdownMenuItem( + text = { Text(stringResource(R.string.template_details)) }, + onClick = { + menuExpanded = false + onDetails() + }, + ) + } + + if (!item.installed) { + DropdownMenuItem( + text = { Text(stringResource(R.string.action_delete_template)) }, + onClick = { + menuExpanded = false + onDelete() + }, + ) + } + } + } + } + } +} + +private fun TemplateProvenance.labelRes(): Int = + when (this) { + TemplateProvenance.BUNDLED -> R.string.template_provenance_bundled + TemplateProvenance.PLUGIN -> R.string.template_provenance_plugin + TemplateProvenance.USER -> R.string.template_provenance_user + } diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerDialogs.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerDialogs.kt new file mode 100644 index 0000000000..c4c1258404 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerDialogs.kt @@ -0,0 +1,165 @@ +package com.itsaky.androidide.ui.compose.templates + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.R +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.templates.manager.models.TemplateMetadata +import com.itsaky.androidide.templates.manager.models.displayName +import com.itsaky.androidide.templates.manager.models.primaryTemplate +import com.itsaky.androidide.templates.manager.models.versionLabel + +@Composable +fun DeleteTemplateConfirmationDialog( + item: CgtFileItem, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_delete_template)) }, + text = { Text(stringResource(R.string.msg_delete_template_confirm, item.displayName)) }, + confirmButton = { + TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete_template)) } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.cancel)) } + }, + ) +} + +/** File-level details for a single-template .cgt (multi-template files use [TemplateListDialog]). */ +@Composable +fun TemplateFileDetailsDialog( + item: CgtFileItem, + onDismiss: () -> Unit, +) { + val primary = item.primaryTemplate + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(primary.name.ifBlank { item.displayName }) }, + text = { + SelectionContainer { + Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + DetailRow(stringResource(R.string.label_template_file), item.displayName) + DetailRow( + stringResource(R.string.label_template_status), + stringResource( + if (item.installed) R.string.status_template_installed else R.string.status_template_not_installed, + ), + ) + DetailRow(stringResource(R.string.label_template_location), item.file.absolutePath) + TemplateMetadataDetails(primary) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_close)) } + }, + ) +} + +/** Details for a single template selected from the [TemplateListDialog] sub-screen. */ +@Composable +fun TemplateDetailsDialog( + template: TemplateMetadata, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(template.name.ifBlank { stringResource(R.string.template_unnamed) }) }, + text = { + SelectionContainer { + Column(modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState())) { + TemplateMetadataDetails(template) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_close)) } + }, + ) +} + +@Composable +private fun TemplateMetadataDetails(template: TemplateMetadata) { + val versionText = versionLabel(template.version) + if (versionText.isNotBlank()) { + DetailRow(stringResource(R.string.label_template_version), versionText) + } + DetailRow(stringResource(R.string.label_template_description), template.description) + if (template.optionalTags.isNotEmpty()) { + Text(stringResource(R.string.label_template_optional_params), style = MaterialTheme.typography.labelLarge) + template.optionalTags.forEach { tag -> Text("• $tag") } + } +} + +@Composable +private fun DetailRow( + label: String, + value: String, +) { + Text("$label: $value") +} + +/** Sub-screen: one card per template bundled inside a multi-template .cgt. */ +@Composable +fun TemplateListDialog( + item: CgtFileItem, + onSelectTemplate: (TemplateMetadata) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.title_templates_in, item.displayName)) }, + text = { + LazyColumn { + items(item.templates) { template -> + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable { onSelectTemplate(template) } + .padding(12.dp), + ) { + Text( + template.name.ifBlank { stringResource(R.string.template_unnamed) }, + style = MaterialTheme.typography.titleSmall, + ) + val versionText = versionLabel(template.version) + if (versionText.isNotBlank()) { + Text(versionText, style = MaterialTheme.typography.labelSmall) + } + Text(template.description, style = MaterialTheme.typography.bodySmall) + } + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.btn_close)) } + }, + ) +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerScreen.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerScreen.kt new file mode 100644 index 0000000000..7a0bea8996 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateManagerScreen.kt @@ -0,0 +1,211 @@ +package com.itsaky.androidide.ui.compose.templates + +import android.content.ClipData +import android.content.ClipboardManager +import androidx.activity.ComponentActivity +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.templates.manager.models.CgtFileItem +import com.itsaky.androidide.templates.manager.models.TemplateMetadata +import com.itsaky.androidide.ui.models.TemplateManagerUiEffect +import com.itsaky.androidide.ui.models.TemplateManagerUiEvent +import com.itsaky.androidide.utils.DURATION_INDEFINITE +import com.itsaky.androidide.utils.errorIcon +import com.itsaky.androidide.utils.flashSuccess +import com.itsaky.androidide.utils.flashbarBuilder +import com.itsaky.androidide.utils.showOnUiThread +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel + +private sealed interface TemplateManagerDialogState { + data object None : TemplateManagerDialogState + + data class DeleteConfirm( + val item: CgtFileItem, + ) : TemplateManagerDialogState + + data class FileDetails( + val item: CgtFileItem, + ) : TemplateManagerDialogState + + data class TemplateList( + val item: CgtFileItem, + ) : TemplateManagerDialogState +} + +/** + * Templates tab content (ADR 0009). Passively scans `Environment.TEMPLATES_DIR` + the Downloads + * folder for `.cgt` files - unlike the Plugins tab, there's no FAB/file-picker install flow here, + * matching the reference `TemplateManagerPlugin`'s design. + * + * Content-only (no Scaffold/TopAppBar): meant to be composed as one tab's body inside the shared + * manager screen alongside the Plugins tab. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun TemplateManagerScreen( + activity: ComponentActivity, + viewModel: TemplateManagerViewModel, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var dialogState by remember { mutableStateOf(TemplateManagerDialogState.None) } + var selectedTemplateDetails by remember { mutableStateOf(null) } + val rootView = LocalView.current + + fun showTooltip() { + TooltipManager.showIdeCategoryTooltip(activity, rootView, TooltipTag.TEMPLATE_MANAGER) + } + + LaunchedEffect(viewModel) { + viewModel.uiEffect.collect { effect -> + when (effect) { + is TemplateManagerUiEffect.ShowError -> { + val message = activity.getString(effect.messageResId, *effect.formatArgs.toTypedArray()) + val builder = + activity + .flashbarBuilder(duration = if (effect.formatArgs.isEmpty()) 5000L else DURATION_INDEFINITE) + .errorIcon() + .message(message) + if (effect.formatArgs.isNotEmpty()) { + builder + .positiveActionText(R.string.copy) + .positiveActionTapListener { bar -> + activity + .getSystemService(ClipboardManager::class.java) + ?.setPrimaryClip( + ClipData.newPlainText(activity.getString(R.string.msg_template_error_clip_label), message), + ) + bar.dismiss() + } + } + builder.showOnUiThread() + } + + is TemplateManagerUiEffect.ShowSuccess -> { + activity.flashSuccess(activity.getString(effect.messageResId)) + } + + is TemplateManagerUiEffect.ShowDeleteConfirmation -> { + dialogState = TemplateManagerDialogState.DeleteConfirm(effect.item) + } + + is TemplateManagerUiEffect.ShowTemplateDetails -> { + dialogState = TemplateManagerDialogState.FileDetails(effect.item) + } + + is TemplateManagerUiEffect.ShowTemplateList -> { + dialogState = TemplateManagerDialogState.TemplateList(effect.item) + } + } + } + } + + Box( + modifier = + modifier + .fillMaxSize() + .pointerInput(Unit) { detectTapGestures(onLongPress = { showTooltip() }) }, + ) { + if (uiState.isEmpty) { + TemplateManagerEmptyState(modifier = Modifier.fillMaxSize()) + } else { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { + items(uiState.items, key = { it.file.absolutePath }) { item -> + TemplateListItem( + item = item, + onInstall = { viewModel.onEvent(TemplateManagerUiEvent.InstallTemplate(item)) }, + onUninstall = { viewModel.onEvent(TemplateManagerUiEvent.UninstallTemplate(item)) }, + onDetails = { viewModel.onEvent(TemplateManagerUiEvent.ShowTemplateDetails(item)) }, + onDelete = { viewModel.onEvent(TemplateManagerUiEvent.DeleteDownloadFile(item)) }, + onViewTemplates = { viewModel.onEvent(TemplateManagerUiEvent.ShowTemplateList(item)) }, + onLongPressTooltip = { showTooltip() }, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + } + } + } + + when (val dialog = dialogState) { + is TemplateManagerDialogState.None -> {} + + is TemplateManagerDialogState.DeleteConfirm -> { + DeleteTemplateConfirmationDialog( + item = dialog.item, + onConfirm = { + viewModel.confirmDeleteDownloadFile(dialog.item) + dialogState = TemplateManagerDialogState.None + }, + onDismiss = { dialogState = TemplateManagerDialogState.None }, + ) + } + + is TemplateManagerDialogState.FileDetails -> { + TemplateFileDetailsDialog( + item = dialog.item, + onDismiss = { dialogState = TemplateManagerDialogState.None }, + ) + } + + is TemplateManagerDialogState.TemplateList -> { + TemplateListDialog( + item = dialog.item, + onSelectTemplate = { template -> selectedTemplateDetails = template }, + onDismiss = { dialogState = TemplateManagerDialogState.None }, + ) + } + } + + selectedTemplateDetails?.let { template -> + TemplateDetailsDialog(template = template, onDismiss = { selectedTemplateDetails = null }) + } +} + +@Composable +private fun TemplateManagerEmptyState(modifier: Modifier = Modifier) { + Box(modifier = modifier, contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon( + painter = painterResource(R.drawable.ic_docs), + contentDescription = null, + modifier = + Modifier + .size(64.dp) + .padding(bottom = 16.dp), + ) + Text(stringResource(R.string.no_templates_found), style = MaterialTheme.typography.headlineSmall) + Text( + stringResource(R.string.no_templates_found_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} 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 a94cc33edf..e039b56046 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -49,6 +49,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 TEMPLATE_MANAGER = "template.manager" const val TEMPLATE_TABBED_ACTIVITY = "template.tabbed.activity" const val TEMPLATE_LEGACY_PROJECT = "template.legacy.project" const val TEMPLATE_EMPTY_ACTIVITY = "template.empty.activity" diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 16283533fc..dedc887c72 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1256,6 +1256,34 @@ Plugin Manager + + Plugins + Templates + No templates found + Templates you download show up here for installing + Installed + Not installed + Bundled + From plugin + Imported + Contains %1$d templates + Install + Uninstall + View templates + Delete + Details + Templates in %1$s + Delete template? + This permanently deletes \'%1$s\' from Downloads. + File + Status + Location + Version + Description + Optional parameters + (unnamed) + Template error + Failed to save bitmap to file PixelCopy failed Failed to capture or save screenshot From 81e3797abfb5960643168226a0ecaedff63074a1 Mon Sep 17 00:00:00 2001 From: yaturner Date: Thu, 30 Jul 2026 07:33:22 -0700 Subject: [PATCH 05/16] ADFA-4928: Wire Plugins and Templates tabs together New ManagerScreen composable owns the shared Scaffold/TopAppBar/TabRow + HorizontalPager, hosting Plugins and Templates as pages (Plugins default). The FAB and discover-plugins action only render on the Plugins tab, since Templates is a passive Downloads-folder scan with no equivalent action. Refactors the old PluginManagerScreen into PluginManagerContent - a Scaffold-free content composable, matching TemplateManagerScreen's shape - so both tabs plug into ManagerScreen's single Scaffold instead of nesting their own. PluginManagerActivity now resolves both PluginManagerViewModel and TemplateManagerViewModel and renders ManagerScreen; its class name and entry points (Settings, the crash-recovery dialog) are unchanged. Updates ARCHITECTURE.md: this is the first production Compose screen in app (ADR 0009), and templates/manager is a new data-layer package. Verified end-to-end on a physical device: assembleV8Debug, installed APK, exercised both tabs from Settings -> Plugin Manager. Templates tab correctly scanned Environment.TEMPLATES_DIR + Downloads (found real pre-existing .cgt fixtures on the test device), and a full install/uninstall round-trip moved files between Downloads and TEMPLATES_DIR and refreshed the list correctly. No crashes. --- .../androidide/ui/compose/ManagerScreen.kt | 135 ++++++++++++++++++ ...nagerScreen.kt => PluginManagerContent.kt} | 0 2 files changed, 135 insertions(+) create mode 100644 app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt rename app/src/main/java/com/itsaky/androidide/ui/compose/plugins/{PluginManagerScreen.kt => PluginManagerContent.kt} (100%) diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt new file mode 100644 index 0000000000..86a8de26fb --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt @@ -0,0 +1,135 @@ +package com.itsaky.androidide.ui.compose + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import com.itsaky.androidide.R +import com.itsaky.androidide.ui.compose.plugins.PluginManagerContent +import com.itsaky.androidide.ui.compose.templates.TemplateManagerScreen +import com.itsaky.androidide.ui.models.PluginManagerUiEvent +import com.itsaky.androidide.utils.UrlManager +import com.itsaky.androidide.viewmodels.PluginManagerViewModel +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel +import kotlinx.coroutines.launch + +private const val TAB_PLUGINS = 0 +private const val TAB_TEMPLATES = 1 + +/** + * Root screen for `PluginManagerActivity` (ADFA-4928): a single manager with two tabs, Plugins + * and Templates, defaulting to Plugins. Owns the one shared Scaffold/TopAppBar; the FAB and + * discover-plugins action only apply to the Plugins tab, since the Templates tab is a passive + * scan of the Downloads folder with no equivalent action. + * + * Forwards each tab's ViewModel one level down to its own content composable rather than + * hoisting all plugin/template UI state up into this shared screen - matches this repo's + * established Koin `by viewModel()` + pass-as-parameter pattern (no koinViewModel() dependency). + */ +@Suppress("ktlint:compose:vm-forwarding-check") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun ManagerScreen( + activity: ComponentActivity, + pluginViewModel: PluginManagerViewModel, + templateViewModel: TemplateManagerViewModel, + modifier: Modifier = Modifier, +) { + val pagerState = rememberPagerState(pageCount = { 2 }) + val coroutineScope = rememberCoroutineScope() + + Scaffold( + modifier = modifier, + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_manager)) }, + navigationIcon = { + IconButton(onClick = { activity.onBackPressedDispatcher.onBackPressed() }) { + Icon( + painter = painterResource(R.drawable.ic_back), + contentDescription = stringResource(android.R.string.cancel), + ) + } + }, + actions = { + if (pagerState.currentPage == TAB_PLUGINS) { + IconButton( + onClick = { + UrlManager.openUrl(activity.getString(R.string.url_discover_plugins), null, activity) + }, + ) { + Icon( + painter = painterResource(R.drawable.ic_download), + contentDescription = stringResource(R.string.action_discover_plugins), + ) + } + } + }, + ) + }, + floatingActionButton = { + if (pagerState.currentPage == TAB_PLUGINS) { + FloatingActionButton( + onClick = { pluginViewModel.onEvent(PluginManagerUiEvent.OpenFilePicker) }, + ) { + Icon( + painter = painterResource(R.drawable.ic_add), + contentDescription = stringResource(R.string.cd_add), + ) + } + } + }, + ) { padding -> + Column(modifier = Modifier.padding(padding).fillMaxSize()) { + TabRow(selectedTabIndex = pagerState.currentPage) { + Tab( + selected = pagerState.currentPage == TAB_PLUGINS, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(TAB_PLUGINS) } }, + text = { Text(stringResource(R.string.tab_plugins)) }, + ) + Tab( + selected = pagerState.currentPage == TAB_TEMPLATES, + onClick = { coroutineScope.launch { pagerState.animateScrollToPage(TAB_TEMPLATES) } }, + text = { Text(stringResource(R.string.tab_templates)) }, + ) + } + + HorizontalPager(state = pagerState, modifier = Modifier.fillMaxSize()) { page -> + when (page) { + TAB_PLUGINS -> { + PluginManagerContent( + activity = activity, + viewModel = pluginViewModel, + modifier = Modifier.fillMaxSize(), + ) + } + + TAB_TEMPLATES -> { + TemplateManagerScreen( + activity = activity, + viewModel = templateViewModel, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerScreen.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt similarity index 100% rename from app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerScreen.kt rename to app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt From 3a9cd97f6bc00d3f16f01766f96304ea9d7dd977 Mon Sep 17 00:00:00 2001 From: yaturner Date: Thu, 30 Jul 2026 07:34:45 -0700 Subject: [PATCH 06/16] ADFA-4928: Complete tab wiring (PluginManagerContent refactor + activity + docs) Finishes the previous commit: a staging mistake (a `git add` call hit a stale pathspec and aborted before reaching these files) left `81e3797ab` with only the new `ManagerScreen.kt` and a content-less file rename, referencing a `PluginManagerContent` composable that didn't exist yet in that commit alone - not independently buildable. This commit adds what was missed: the actual `PluginManagerContent.kt` refactor (Scaffold/TopAppBar/FAB stripped out, now content-only), `PluginManagerActivity.kt` wired to render `ManagerScreen` with both view models, the `ARCHITECTURE.md` updates, and the `title_manager` string. Combined history through this commit compiles (:app:compileV8DebugKotlin) and matches what was already verified end-to-end on-device in the previous message. --- ARCHITECTURE.md | 14 +-- .../activities/PluginManagerActivity.kt | 12 +- .../compose/plugins/PluginManagerContent.kt | 103 ++++++------------ resources/src/main/res/values/strings.xml | 3 +- 4 files changed, 49 insertions(+), 83 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4be5ac177e..92dc9dd56e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -6,16 +6,16 @@ Code On The Go (CoGo) is a full Android IDE that runs **on the device** — it edits, builds, and deploys real Android apps offline, embedding a Termux toolchain and running an actual Gradle build in a separate process via the `tooling-api`. It is the maintained successor to AndroidIDE, so the codebase namespace is still `com.itsaky.androidide`. -There is **no single architectural philosophy** across the whole app. This large, layered application is still **predominantly View-based**: newer feature surfaces (plugin manager, AI agent, git, project list) follow a deliberate **Unidirectional Data Flow (UDF)** with Koin DI, `ViewModel` + `StateFlow`, sealed UI-state/effect types, and repositories, while older surfaces still use `LiveData` and talk to GreenRobot EventBus directly. New work follows the UDF pattern documented below, and new UI is built in **Jetpack Compose** ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)) — Compose replaces the view layer only; the UDF stack (ViewModel + `StateFlow`, Koin, repositories) is unchanged. Existing XML/View screens remain until substantially reworked. +There is **no single architectural philosophy** across the whole app. This large, layered application is still **predominantly View-based**: newer feature surfaces (plugin manager, AI agent, git, project list) follow a deliberate **Unidirectional Data Flow (UDF)** with Koin DI, `ViewModel` + `StateFlow`, sealed UI-state/effect types, and repositories, while older surfaces still use `LiveData` and talk to GreenRobot EventBus directly. New work follows the UDF pattern documented below, and new UI is built in **Jetpack Compose** ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)) — Compose replaces the view layer only; the UDF stack (ViewModel + `StateFlow`, Koin, repositories) is unchanged. Existing XML/View screens remain until substantially reworked. The first production example is the **Manager** screen (`PluginManagerActivity`) — merged Plugins/Templates tabs built with `Scaffold`/`TabRow`/`HorizontalPager` (ADFA-4928). ## Core Architecture & Data Flow -Feature code layers as **UI → ViewModel → Repository → data source**, with state flowing up and events/intents flowing down. Koin provides dependencies (`coreModule`, `pluginModule`), constructor-injected into ViewModels. +Feature code layers as **UI → ViewModel → Repository → data source**, with state flowing up and events/intents flowing down. Koin provides dependencies (`coreModule`, `pluginModule`, `templateModule`), constructor-injected into ViewModels. - **Data sources** — Room (`RecentProjectRoomDatabase` + DAO, `suspend` functions), raw SQLite (`SQLiteOpenHelper`, e.g. `localWebServer/WebServer`), the filesystem/preferences, the embedded `tooling-api` (on-device Gradle), and external clients (Gemini via the Google GenAI SDK, on-device llama.cpp, JGit). Most are exposed through `suspend` functions. -- **Repositories** — e.g. `agent/repository/GeminiRepository`, `repositories/PluginRepository`, `repositories/BreakpointRepository`. They wrap data sources and hide threading/IO from the ViewModel. +- **Repositories** — e.g. `agent/repository/GeminiRepository`, `repositories/PluginRepository`, `repositories/TemplateRepository`, `repositories/BreakpointRepository`. They wrap data sources and hide threading/IO from the ViewModel. - **ViewModels** — run work in `viewModelScope` on `Dispatchers.IO`, hold a private `MutableStateFlow`/`MutableSharedFlow`, and expose read-only `StateFlow`/`SharedFlow`. One-shot effects (toasts, navigation, dialogs) go through a separate `SharedFlow` of a sealed `*UiEffect` type. -- **UI (Fragments / Activities / Views)** — collect state in a lifecycle-aware coroutine and render it; user actions return to the ViewModel as method calls or sealed `*UiEvent` intents. The existing UI is **Android Views + Fragments + RecyclerView adapters**; new UI is Jetpack Compose ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)). (`compose-preview` previews the *user's* Compose code, not CoGo's own.) +- **UI (Fragments / Activities / Views)** — collect state in a lifecycle-aware coroutine and render it; user actions return to the ViewModel as method calls or sealed `*UiEvent` intents. The existing UI is **Android Views + Fragments + RecyclerView adapters**; new UI is Jetpack Compose ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)) — first used in the Manager screen (`ui/compose/ManagerScreen.kt`, ADFA-4928). (`compose-preview` previews the *user's* Compose code, not CoGo's own.) ``` ┌─────────────────────────────────────────────┐ @@ -83,14 +83,14 @@ These structural facts shape every module. Day-to-day build *commands* live in ` - **SDK levels** (`build-logic/.../build/config/BuildConfig.kt`): `COMPILE_SDK=36`, `MIN_SDK=28`, `TARGET_SDK=28`. **`TARGET_SDK` is deliberately pinned at 28:** higher targets enforce W^X (write-xor-execute), which blocks executing code from app-writable files. That is fatal for an on-device IDE that compiles and runs code (Gradle, `javac`, Termux binaries), so it is a hard requirement, not tech debt. `MIN_SDK_FOR_APPS_BUILT_WITH_COGO=16` is the floor for the apps a *user* builds with CoGo — distinct from CoGo's own `MIN_SDK`. - **Native asset bundling.** The on-device LLM (`llama-impl`) ships as a per-flavor native AAR, wired through the root `build.gradle.kts` (`bundleLlamaV8Assets` / `assembleV8Assets`, …); prebuilt per-flavor assets live under `assets/release/v7/` and `assets/release/v8/`. - **Native lib compression** (ADFA-2306, ADFA-4729). The app manifest hard-codes `android:extractNativeLibs="true"` (required: the installer must materialize libs in `nativeLibraryDir`, e.g. `libshizuku.so` is an executable the adb shell runs from there). That attribute overrides the `jniLibs.useLegacyPackaging` DSL, so AGP packages `lib//*.so` deflate-compressed in **every** APK — ~5.9 MB smaller (`libtree-sitter-kotlin.so` alone is 4.18 MB → 339 kB). The trap is the `recompressApk` post-step (release always, debug in CI only): its no-compress lists in `app/build.gradle.kts` must NOT contain `"so"`, or it silently re-stores the libs and undoes the saving — which is what ADFA-2306 fixed for release and ADFA-4729 for CI debug. Locally built debug APKs (including the e2e farm's) never run that step and were always fine. -- **`app` package layout is by concern, not feature:** `activities`, `fragments`, `services`, `di`, `agent`, `viewmodel(s)`, `repositories`, `roomData`, `localWebServer`, `preferences`, `ui`, `utils`, …. +- **`app` package layout is by concern, not feature:** `activities`, `fragments`, `services`, `di`, `agent`, `viewmodel(s)`, `repositories`, `roomData`, `localWebServer`, `preferences`, `ui` (Compose screens live under `ui/compose`), `templates/manager` (the Manager screen's `.cgt`-parsing data layer, with direct filesystem access to `Environment.TEMPLATES_DIR` — distinct from the plugin-facing `IdeTemplateService` in `plugin-api`/`plugin-manager`), `utils`, …. ## Technology Stack | Concern | Library / Approach | |---|---| -| UI | **Jetpack Compose for all new UI** ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)). The existing majority is still Android Views + Fragments + `RecyclerView` (Material Components); those legacy screens stay until reworked, but new IDE UI is Compose-only. | -| Dependency Injection | **Koin** (`org.koin`) — `coreModule`/`pluginModule`, `startKoin` in `IDEApplication`, plus a `ServiceLocator : KoinComponent` for lazy post-startup access. No Hilt/Dagger. | +| UI | **Jetpack Compose for all new UI** ([ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)); first production screen is the Manager screen (Plugins/Templates tabs, `app/.../ui/compose/`, ADFA-4928). The existing majority is still Android Views + Fragments + `RecyclerView` (Material Components); those legacy screens stay until reworked, but new IDE UI is Compose-only. | +| Dependency Injection | **Koin** (`org.koin`) — `coreModule`/`pluginModule`/`templateModule`, `startKoin` in `IDEApplication`, plus a `ServiceLocator : KoinComponent` for lazy post-startup access. No Hilt/Dagger. | | Asynchronous work | **Kotlin Coroutines + Flow** (`StateFlow`/`SharedFlow`, `viewModelScope`, app-scoped `CoroutineScope(SupervisorJob() + Dispatchers.IO)`); **GreenRobot EventBus** for cross-subsystem events. | | Networking | Offline-first; no general REST layer. External I/O is **Google GenAI SDK** (Gemini), **on-device llama.cpp**, and **JGit** (git). Retrofit is in the catalog but effectively unused in app code. | | Database / Persistence | **Room** is the default for relational/queryable data; **filesystem + preferences (DataStore)** for non-relational settings. **Raw SQLite** (`SQLiteDatabase` / `SupportSQLiteOpenHelper`) only for justified exceptions (see policy below). | 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 0aa0489259..b3b635ffb8 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -9,10 +9,11 @@ import com.itsaky.androidide.FeedbackButtonManager import com.itsaky.androidide.R import com.itsaky.androidide.app.EdgeToEdgeIDEActivity import com.itsaky.androidide.databinding.ActivityPluginManagerBinding -import com.itsaky.androidide.ui.compose.plugins.PluginManagerScreen +import com.itsaky.androidide.ui.compose.ManagerScreen import com.itsaky.androidide.ui.compose.theme.ManagerTheme import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.viewmodels.PluginManagerViewModel +import com.itsaky.androidide.viewmodels.TemplateManagerViewModel import org.koin.androidx.viewmodel.ext.android.viewModel class PluginManagerActivity : EdgeToEdgeIDEActivity() { @@ -23,7 +24,8 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { private var feedbackButtonManager: FeedbackButtonManager? = null - private val viewModel: PluginManagerViewModel by viewModel() + private val pluginViewModel: PluginManagerViewModel by viewModel() + private val templateViewModel: TemplateManagerViewModel by viewModel() override fun bindLayout(): View { _binding = ActivityPluginManagerBinding.inflate(layoutInflater) @@ -36,7 +38,11 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { binding.composeView.setContent { ManagerTheme { - PluginManagerScreen(activity = this, viewModel = viewModel) + ManagerScreen( + activity = this, + pluginViewModel = pluginViewModel, + templateViewModel = templateViewModel, + ) } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt index b826f8dc69..8d52df604d 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt @@ -17,14 +17,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -48,7 +43,6 @@ import com.itsaky.androidide.ui.models.PluginManagerUiEffect import com.itsaky.androidide.ui.models.PluginManagerUiEvent import com.itsaky.androidide.utils.DURATION_INDEFINITE import com.itsaky.androidide.utils.DialogUtils -import com.itsaky.androidide.utils.UrlManager import com.itsaky.androidide.utils.errorIcon import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess @@ -57,7 +51,7 @@ import com.itsaky.androidide.utils.getFileName import com.itsaky.androidide.utils.showOnUiThread import com.itsaky.androidide.viewmodels.PluginManagerViewModel -private const val TAG = "PluginManagerScreen" +private const val TAG = "PluginManagerContent" private const val PLUGIN_EXTENSION = ".cgp" private fun Uri.isSupportedPluginFile(activity: ComponentActivity): Boolean = @@ -87,18 +81,23 @@ private sealed interface PluginManagerDialogState { } /** - * Compose port of the legacy `PluginManagerActivity`/`activity_plugin_manager.xml` screen (ADR 0009). - * Preserves every capability of the original: install (via SAF picker)/enable/disable/uninstall, - * overwrite/signature-mismatch conflict handling, restart prompt, and the discover-plugins action. + * Plugins tab content (ADR 0009). Preserves every capability of the original + * `PluginManagerActivity`/`activity_plugin_manager.xml` screen: install (via SAF picker; + * the launcher lives here, the FAB that triggers it lives in + * [com.itsaky.androidide.ui.compose.ManagerScreen], which owns the shared Scaffold)/ + * enable/disable/uninstall, overwrite/signature-mismatch conflict handling, restart prompt. + * + * Content-only (no Scaffold/TopAppBar/FAB): composed as one tab's body inside the shared manager + * screen alongside the Templates tab. * * The original wired the same long-press tooltip (`TooltipTag.PLUGIN_MANAGER`) to six separate * views. Since they all show identical content, this collapses to two anchor points here: each * list item (already handles its own tap-for-details gesture) and the screen's background/empty * state area - long-pressing anywhere else on the screen shows the same tooltip. */ -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable -fun PluginManagerScreen( +fun PluginManagerContent( activity: ComponentActivity, viewModel: PluginManagerViewModel, modifier: Modifier = Modifier, @@ -190,66 +189,26 @@ fun PluginManagerScreen( } } - Scaffold( - modifier = modifier, - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.title_plugin_manager)) }, - navigationIcon = { - IconButton(onClick = { activity.onBackPressedDispatcher.onBackPressed() }) { - Icon( - painter = painterResource(R.drawable.ic_back), - contentDescription = stringResource(android.R.string.cancel), - ) - } - }, - actions = { - IconButton( - onClick = { - UrlManager.openUrl(activity.getString(R.string.url_discover_plugins), null, activity) - }, - ) { - Icon( - painter = painterResource(R.drawable.ic_download), - contentDescription = stringResource(R.string.action_discover_plugins), - ) - } - }, - ) - }, - floatingActionButton = { - FloatingActionButton( - onClick = { viewModel.onEvent(PluginManagerUiEvent.OpenFilePicker) }, - ) { - Icon( - painter = painterResource(R.drawable.ic_add), - contentDescription = stringResource(R.string.cd_add), - ) - } - }, - ) { padding -> - Box( - modifier = - Modifier - .padding(padding) - .fillMaxSize() - .pointerInput(Unit) { detectTapGestures(onLongPress = { showTooltip() }) }, - ) { - if (uiState.showEmptyState) { - PluginManagerEmptyState(modifier = Modifier.fillMaxSize()) - } else { - LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { - items(uiState.plugins, key = { it.metadata.id }) { plugin -> - PluginListItem( - plugin = plugin, - onEnable = { viewModel.onEvent(PluginManagerUiEvent.EnablePlugin(plugin.metadata.id)) }, - onDisable = { viewModel.onEvent(PluginManagerUiEvent.DisablePlugin(plugin.metadata.id)) }, - onUninstall = { viewModel.onEvent(PluginManagerUiEvent.UninstallPlugin(plugin.metadata.id)) }, - onDetails = { viewModel.onEvent(PluginManagerUiEvent.ShowPluginDetails(plugin)) }, - onLongPressTooltip = { showTooltip() }, - modifier = Modifier.padding(bottom = 8.dp), - ) - } + Box( + modifier = + modifier + .fillMaxSize() + .pointerInput(Unit) { detectTapGestures(onLongPress = { showTooltip() }) }, + ) { + if (uiState.showEmptyState) { + PluginManagerEmptyState(modifier = Modifier.fillMaxSize()) + } else { + LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { + items(uiState.plugins, key = { it.metadata.id }) { plugin -> + PluginListItem( + plugin = plugin, + onEnable = { viewModel.onEvent(PluginManagerUiEvent.EnablePlugin(plugin.metadata.id)) }, + onDisable = { viewModel.onEvent(PluginManagerUiEvent.DisablePlugin(plugin.metadata.id)) }, + onUninstall = { viewModel.onEvent(PluginManagerUiEvent.UninstallPlugin(plugin.metadata.id)) }, + onDetails = { viewModel.onEvent(PluginManagerUiEvent.ShowPluginDetails(plugin)) }, + onLongPressTooltip = { showTooltip() }, + modifier = Modifier.padding(bottom = 8.dp), + ) } } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index dedc887c72..0de52e0da0 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1256,7 +1256,8 @@ Plugin Manager - + + Plugins & Templates Plugins Templates No templates found From 4fd933de13bc6cc651651de4849896c1c021b9e3 Mon Sep 17 00:00:00 2001 From: yaturner Date: Thu, 30 Jul 2026 08:08:19 -0700 Subject: [PATCH 07/16] ADFA-4928: Rename the preferences entry to Extensions Manager The Settings entry that opens the merged Plugins/Templates screen was still titled "Plugin Manager" with a summary mentioning "extensions" (the old plugin-only wording). Renamed to "Extensions Manager" with a summary reflecting both tabs it now opens: "Manage IDE plugins and templates". Verified on-device: preferences list and the opened screen both render correctly. --- resources/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 0de52e0da0..f2522874b1 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -893,8 +893,8 @@ Plugin Manager - Plugin Manager - Manage IDE plugins and extensions + Extensions Manager + Manage IDE plugins and templates Plugins No plugins installed Tap the + button to install your first plugin From 7207d904290631b29d4939ef9665be7bf227cc37 Mon Sep 17 00:00:00 2001 From: yaturner Date: Thu, 30 Jul 2026 17:30:12 -0700 Subject: [PATCH 08/16] ADFA-4928: Warm Application.filesDir off the main thread PluginModule's Koin factories called Context.filesDir directly, which does a real File.exists() check on every call, not just the first. That trips StrictMode's DiskReadViolation the first time the Extensions Manager screen resolves PluginRepository/PluginManagerViewModel on the main thread. Cache the resolved File once, off-main, during app startup (IDEApplication.cachedFilesDir), and have PluginModule read that instead - later reads are then a plain field access rather than a syscall. Co-Authored-By: Claude Sonnet 5 --- .../app/DeviceProtectedApplicationLoader.kt | 5 +++ .../itsaky/androidide/app/IDEApplication.kt | 11 ++++++ .../com/itsaky/androidide/di/PluginModule.kt | 35 ++++++++++--------- 3 files changed, 34 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt index 9cf18fcdb8..57362417ea 100644 --- a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt @@ -137,6 +137,11 @@ internal object DeviceProtectedApplicationLoader : app.coroutineScope.launch(Dispatchers.IO) { // early-init theme manager since it may need to perform disk reads IThemeManager.getInstance() + + // warm IDEApplication.cachedFilesDir off-main so later readers (e.g. pluginModule, + // resolved on the main thread on first navigation to the Extensions Manager) don't + // trip StrictMode's DiskReadViolation + IDEApplication.cachedFilesDir } withContext(Dispatchers.Main) { diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index f1eeb34a7b..12a3131240 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -54,6 +54,7 @@ import org.koin.core.context.GlobalContext import org.koin.core.context.startKoin import org.lsposed.hiddenapibypass.HiddenApiBypass import org.slf4j.LoggerFactory +import java.io.File import java.lang.Thread.UncaughtExceptionHandler const val EXIT_CODE_CRASH = 1 @@ -142,6 +143,16 @@ class IDEApplication : @JvmStatic fun getPluginManager(): PluginManager? = CredentialProtectedApplicationLoader.pluginManager + + /** + * [Context.getFilesDir] does a real disk check (`File.exists()`) on every call, not just + * the first - callers on the main thread (e.g. Koin's [pluginModule] resolving on first + * navigation to the Extensions Manager) trip StrictMode's DiskReadViolation. Cache it once, + * off-main, during startup (see [DeviceProtectedApplicationLoader]) so later reads are a + * plain field access instead of a syscall. + */ + @JvmStatic + val cachedFilesDir: File by lazy { instance.filesDir } } override fun onActivityPostPaused(activity: Activity) { 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..cc7fc479eb 100644 --- a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt +++ b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt @@ -12,22 +12,23 @@ 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(IDEApplication.cachedFilesDir, "plugins"), + ) + } - // ViewModel - viewModel { - PluginManagerViewModel( - pluginRepository = get(), - contentResolver = androidContext().contentResolver, - filesDir = androidContext().filesDir - ) - } -} \ No newline at end of file + // ViewModel + viewModel { + PluginManagerViewModel( + pluginRepository = get(), + contentResolver = androidContext().contentResolver, + filesDir = IDEApplication.cachedFilesDir, + ) + } + } From 755445a13e9b1fb04f7af6ccbdec5d21732a485b Mon Sep 17 00:00:00 2001 From: yaturner Date: Thu, 30 Jul 2026 17:36:52 -0700 Subject: [PATCH 09/16] ADFA-4928: Narrow the plugin install file picker to .cgp-like files The SAF picker launched with "*/*", showing every file regardless of type. SAF filters by MIME, not extension, and .cgp has no registered MIME type, so the closest working filter is "application/octet-stream" - what document providers report for files with an unrecognized extension. This hides files with a known type (zips, jars, images, ...) while leaving .cgp files selectable. isSupportedPluginFile() still validates the actual pick, since this is an approximation, not an exact extension filter (SAF has no such thing). Co-Authored-By: Claude Sonnet 5 --- .../androidide/ui/compose/plugins/PluginManagerContent.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt index 8d52df604d..80f657adfe 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt @@ -162,7 +162,12 @@ fun PluginManagerContent( is PluginManagerUiEffect.OpenFilePicker -> { try { - filePickerLauncher.launch(arrayOf("*/*")) + // SAF filters by MIME type, not extension, and .cgp has no registered MIME + // type. "application/octet-stream" is what document providers report for + // files with an unrecognized extension, so this hides files with a known + // type (images, zips, apks, ...) without excluding .cgp files. isSupportedPluginFile + // still validates the actual pick, since this is an approximation. + filePickerLauncher.launch(arrayOf("application/octet-stream")) } catch (_: Exception) { activity.flashError(activity.getString(R.string.msg_no_file_manager)) } From 2cb3d2f81b5f83b17af1fa662b28065db6e309cd Mon Sep 17 00:00:00 2001 From: yaturner Date: Wed, 5 Aug 2026 10:58:22 -0700 Subject: [PATCH 10/16] ADFA-4928: Harden TemplateRepositoryImpl file operations Address CodeRabbit review feedback on PR #1627: - Use SLF4J logging instead of android.util.Log - Narrow runCatching to expected I/O/parsing exceptions, rethrowing CancellationException instead of swallowing it - Refuse to install/uninstall over an existing same-name destination file instead of silently overwriting it - Treat a failed source-file delete as an install/uninstall failure and roll back the copied destination file --- .../repositories/TemplateRepositoryImpl.kt | 91 +++++++++++++++---- 1 file changed, 71 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt index f718c755ec..195ac026b7 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateRepositoryImpl.kt @@ -1,13 +1,15 @@ package com.itsaky.androidide.repositories -import android.util.Log import com.itsaky.androidide.templates.ITemplateProvider import com.itsaky.androidide.templates.manager.models.CgtFileItem import com.itsaky.androidide.templates.manager.models.TemplateProvenance import com.itsaky.androidide.templates.manager.parsing.CgtTemplateReader +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.adfa.constants.TEMPLATE_CORE_ARCHIVE +import org.json.JSONException +import org.slf4j.LoggerFactory import java.io.File import java.io.IOException @@ -24,15 +26,24 @@ class TemplateRepositoryImpl( private val downloadDir: File, ) : TemplateRepository { private companion object { - private const val TAG = "TemplateRepository" + private val logger = LoggerFactory.getLogger(TemplateRepositoryImpl::class.java) private const val CGT_EXTENSION = "cgt" private const val PLUGIN_CGT_PREFIX = "plugin_" } override suspend fun listTemplateFiles(): Result> = withContext(Dispatchers.IO) { - runCatching { scanTemplates() } - .onFailure { exception -> Log.e(TAG, "Failed to scan template files", exception) } + try { + Result.success(scanTemplates()) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + logger.error("Failed to scan template files", e) + Result.failure(e) + } catch (e: SecurityException) { + logger.error("Failed to scan template files", e) + Result.failure(e) + } } private fun scanTemplates(): List { @@ -53,10 +64,17 @@ class TemplateRepositoryImpl( installed: Boolean, ): CgtFileItem? { val templates = - runCatching { file.inputStream().use(CgtTemplateReader::readTemplates) } - .onFailure { exception -> Log.w(TAG, "Failed to parse ${file.absolutePath}", exception) } - .getOrNull() - ?: return null + try { + file.inputStream().use(CgtTemplateReader::readTemplates) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + logger.warn("Failed to parse {}", file.absolutePath, e) + return null + } catch (e: JSONException) { + logger.warn("Failed to parse {}", file.absolutePath, e) + return null + } if (templates.isEmpty()) return null return CgtFileItem( file = file, @@ -76,39 +94,72 @@ class TemplateRepositoryImpl( override suspend fun installTemplate(item: CgtFileItem): Result = withContext(Dispatchers.IO) { - runCatching { + try { check(!item.installed) { "'${item.name}' is already installed" } val dest = File(templatesDir, item.file.name) - item.file.copyTo(dest, overwrite = true) - item.file.delete() + check(!dest.exists()) { "A template named '${dest.name}' already exists in $templatesDir" } + item.file.copyTo(dest, overwrite = false) + if (!item.file.delete()) { + dest.delete() + throw IOException("Failed to delete source file after copying: ${item.file.absolutePath}") + } ITemplateProvider.getInstance(reload = true) - }.onFailure { exception -> Log.e(TAG, "Failed to install template: ${item.name}", exception) } - .map {} + Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + logger.error("Failed to install template: {}", item.name, e) + Result.failure(e) + } catch (e: IllegalStateException) { + logger.error("Failed to install template: {}", item.name, e) + Result.failure(e) + } } override suspend fun uninstallTemplate(item: CgtFileItem): Result = withContext(Dispatchers.IO) { - runCatching { + try { check(item.installed) { "'${item.name}' is not installed" } check(item.provenance != TemplateProvenance.BUNDLED) { "Cannot uninstall the bundled template" } // Restore a copy to Downloads BEFORE removing it from the store: if the restore // throws, the store copy below is never touched, so the user's only copy survives. val restored = File(downloadDir, item.file.name) - item.file.copyTo(restored, overwrite = true) - item.file.delete() + check(!restored.exists()) { "A download named '${restored.name}' already exists in $downloadDir" } + item.file.copyTo(restored, overwrite = false) + if (!item.file.delete()) { + restored.delete() + throw IOException("Failed to delete source file after copying: ${item.file.absolutePath}") + } ITemplateProvider.getInstance(reload = true) - }.onFailure { exception -> Log.e(TAG, "Failed to uninstall template: ${item.name}", exception) } - .map {} + Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + logger.error("Failed to uninstall template: {}", item.name, e) + Result.failure(e) + } catch (e: IllegalStateException) { + logger.error("Failed to uninstall template: {}", item.name, e) + Result.failure(e) + } } override suspend fun deleteDownloadFile(item: CgtFileItem): Result = withContext(Dispatchers.IO) { - runCatching { + try { check(!item.installed) { "Cannot delete an installed template; uninstall it first" } if (!item.file.delete()) { throw IOException("Failed to delete ${item.file.absolutePath}") } - }.onFailure { exception -> Log.e(TAG, "Failed to delete download file: ${item.name}", exception) } + Result.success(Unit) + } catch (e: CancellationException) { + throw e + } catch (e: IOException) { + logger.error("Failed to delete download file: {}", item.name, e) + Result.failure(e) + } catch (e: IllegalStateException) { + logger.error("Failed to delete download file: {}", item.name, e) + Result.failure(e) + } } } From 35ad990057f91ae53ac3aee412593c2099d0aa79 Mon Sep 17 00:00:00 2001 From: yaturner Date: Wed, 5 Aug 2026 10:58:40 -0700 Subject: [PATCH 11/16] ADFA-4928: Keep plugin picker/install work off the main thread Address CodeRabbit review feedback on PR #1627: - Warm IDEApplication.cachedFilesDir on an IO thread before Koin starts, eliminating the race where pluginModule/templateModule could resolve it on the main thread first - Bound FileImage's bitmap decode with inSampleSize and move the file-existence check inside the IO dispatcher; narrow its catch to recoverable failures and let CancellationException propagate - Move the picked plugin file's name/extension validation (a ContentResolver IPC call for content:// URIs) off the picker callback and into PluginManagerViewModel on a background dispatcher, routed back through a new ShowInstallConfirmation effect - Replace android.util.Log with SLF4J logging in PluginManagerContent - Narrow the file-picker launch catch to ActivityNotFoundException and log it instead of silently swallowing any Exception --- .../app/DeviceProtectedApplicationLoader.kt | 5 - .../itsaky/androidide/app/IDEApplication.kt | 12 +- .../androidide/ui/compose/common/FileImage.kt | 51 +- .../compose/plugins/PluginManagerContent.kt | 30 +- .../ui/models/PluginManagerUiState.kt | 125 ++- .../viewmodels/PluginManagerViewModel.kt | 782 ++++++++++-------- .../itsaky/androidide/utils/UriExtensions.kt | 49 +- 7 files changed, 604 insertions(+), 450 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt index 57362417ea..9cf18fcdb8 100644 --- a/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/DeviceProtectedApplicationLoader.kt @@ -137,11 +137,6 @@ internal object DeviceProtectedApplicationLoader : app.coroutineScope.launch(Dispatchers.IO) { // early-init theme manager since it may need to perform disk reads IThemeManager.getInstance() - - // warm IDEApplication.cachedFilesDir off-main so later readers (e.g. pluginModule, - // resolved on the main thread on first navigation to the Extensions Manager) don't - // trip StrictMode's DiskReadViolation - IDEApplication.cachedFilesDir } withContext(Dispatchers.Main) { diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index 12a3131240..b4d8f7326c 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import kotlinx.coroutines.runBlocking import org.koin.android.ext.koin.androidContext import org.koin.core.context.GlobalContext import org.koin.core.context.startKoin @@ -148,8 +149,9 @@ class IDEApplication : * [Context.getFilesDir] does a real disk check (`File.exists()`) on every call, not just * the first - callers on the main thread (e.g. Koin's [pluginModule] resolving on first * navigation to the Extensions Manager) trip StrictMode's DiskReadViolation. Cache it once, - * off-main, during startup (see [DeviceProtectedApplicationLoader]) so later reads are a - * plain field access instead of a syscall. + * off-main, before Koin starts (see the `onCreate()` warmup) so later reads are a plain + * field access instead of a syscall, and pluginModule/templateModule can never be the + * first to trigger the underlying disk read. */ @JvmStatic val cachedFilesDir: File by lazy { instance.filesDir } @@ -194,6 +196,12 @@ class IDEApplication : // https://appdevforall.atlassian.net/browse/ADFA-2026 // https://appdevforall-inc-9p.sentry.io/issues/6860179170/events/7177c576e7b3491c9e9746c76f806d37/ + // Warm cachedFilesDir on an IO thread before Koin starts, so pluginModule/templateModule + // (resolved on the main thread on first navigation to the Extensions Manager) can never + // race the disk read - see cachedFilesDir's doc. The disk access itself runs off-main; + // this only blocks onCreate() waiting for that fast, one-time result. + runBlocking(Dispatchers.IO) { cachedFilesDir } + ensureKoinStarted() coroutineScope.launch(Dispatchers.Default) { diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt index 5d7a25506d..0ca5739f13 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt @@ -1,5 +1,6 @@ package com.itsaky.androidide.ui.compose.common +import android.graphics.Bitmap import android.graphics.BitmapFactory import androidx.compose.foundation.Image import androidx.compose.runtime.Composable @@ -10,6 +11,10 @@ import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File @@ -18,7 +23,9 @@ import java.io.File * Renders [file] as an image, decoded off the main thread, falling back to [placeholder] while * loading, if [file] is null/missing, or if decoding fails. Used for locally-stored icons/thumbnails * (plugin icons, template thumbnails) where the file rarely changes, so a plain decode is enough - * and doesn't warrant an image-loading library dependency. + * and doesn't warrant an image-loading library dependency. Decoding is bounded to [maxDimension] + * (via [BitmapFactory.Options.inSampleSize]) so a large source image doesn't allocate a full-size + * bitmap just to be scaled down to an icon. */ @Composable fun FileImage( @@ -26,16 +33,26 @@ fun FileImage( placeholder: Painter, contentDescription: String?, modifier: Modifier = Modifier, + maxDimension: Dp = 40.dp, ) { - val bitmap by produceState(initialValue = null, file) { + val maxDimensionPx = with(LocalDensity.current) { maxDimension.roundToPx() } + + val bitmap by produceState(initialValue = null, file, maxDimensionPx) { value = - file - ?.takeIf { it.exists() } - ?.let { existing -> - withContext(Dispatchers.IO) { - runCatching { BitmapFactory.decodeFile(existing.absolutePath)?.asImageBitmap() }.getOrNull() + file?.let { candidate -> + withContext(Dispatchers.IO) { + try { + if (!candidate.exists()) return@withContext null + decodeBounded(candidate, maxDimensionPx)?.asImageBitmap() + } catch (e: CancellationException) { + throw e + } catch (e: SecurityException) { + null + } catch (e: OutOfMemoryError) { + null } } + } } val current = bitmap @@ -55,3 +72,23 @@ fun FileImage( ) } } + +/** Decodes [file] downsampled so neither dimension exceeds [maxDimensionPx] by more than 2x. */ +private fun decodeBounded( + file: File, + maxDimensionPx: Int, +): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(file.absolutePath, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + var inSampleSize = 1 + while (bounds.outWidth / (inSampleSize * 2) >= maxDimensionPx && + bounds.outHeight / (inSampleSize * 2) >= maxDimensionPx + ) { + inSampleSize *= 2 + } + + val decodeOptions = BitmapFactory.Options().apply { this.inSampleSize = inSampleSize } + return BitmapFactory.decodeFile(file.absolutePath, decodeOptions) +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt index 80f657adfe..e8d05c71f4 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerContent.kt @@ -1,10 +1,10 @@ package com.itsaky.androidide.ui.compose.plugins +import android.content.ActivityNotFoundException import android.content.ClipData import android.content.ClipboardManager import android.content.Intent import android.net.Uri -import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -47,15 +47,11 @@ import com.itsaky.androidide.utils.errorIcon import com.itsaky.androidide.utils.flashError import com.itsaky.androidide.utils.flashSuccess 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 org.slf4j.LoggerFactory -private const val TAG = "PluginManagerContent" -private const val PLUGIN_EXTENSION = ".cgp" - -private fun Uri.isSupportedPluginFile(activity: ComponentActivity): Boolean = - getFileName(activity).endsWith(PLUGIN_EXTENSION, ignoreCase = true) +private val log = LoggerFactory.getLogger("PluginManagerContent") private sealed interface PluginManagerDialogState { data object None : PluginManagerDialogState @@ -116,14 +112,10 @@ fun PluginManagerContent( try { activity.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION) } catch (e: SecurityException) { - Log.w(TAG, "Could not take persistable URI permission", e) + log.warn("Could not take persistable URI permission", e) } - if (!it.isSupportedPluginFile(activity)) { - activity.flashError(activity.getString(R.string.msg_unsupported_plugin_file)) - } else { - dialogState = PluginManagerDialogState.InstallConfirm(it) - } + viewModel.onEvent(PluginManagerUiEvent.FileSelected(it)) } } @@ -165,14 +157,20 @@ fun PluginManagerContent( // SAF filters by MIME type, not extension, and .cgp has no registered MIME // type. "application/octet-stream" is what document providers report for // files with an unrecognized extension, so this hides files with a known - // type (images, zips, apks, ...) without excluding .cgp files. isSupportedPluginFile - // still validates the actual pick, since this is an approximation. + // type (images, zips, apks, ...) without excluding .cgp files. The + // FileSelected event still validates the actual pick, since this is an + // approximation. filePickerLauncher.launch(arrayOf("application/octet-stream")) - } catch (_: Exception) { + } catch (e: ActivityNotFoundException) { + log.warn("No document provider available for the plugin file picker", e) activity.flashError(activity.getString(R.string.msg_no_file_manager)) } } + is PluginManagerUiEffect.ShowInstallConfirmation -> { + dialogState = PluginManagerDialogState.InstallConfirm(effect.uri) + } + is PluginManagerUiEffect.ShowUninstallConfirmation -> { dialogState = PluginManagerDialogState.UninstallConfirm(effect.plugin) } 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..ec6d8f2bce 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 @@ -6,49 +6,104 @@ import com.itsaky.androidide.plugins.PluginInfo import com.itsaky.androidide.plugins.PluginMetadata 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 } 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 uri: Uri, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEvent() + + data class ConfirmOverwrite( + val uri: Uri, + val deleteSourceAfterInstall: Boolean, + ) : PluginManagerUiEvent() + + object OpenFilePicker : PluginManagerUiEvent() + + data class FileSelected( + val uri: Uri, + ) : 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 ShowInstallConfirmation( + val uri: Uri, + ) : 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() } 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/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index 24043b5f46..bdf9b134a4 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.getFileName import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -31,367 +32,424 @@ import java.io.File * 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, ) : 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 const val PLUGIN_EXTENSION = ".cgp" + } + + // 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.FileSelected -> { + handleFileSelected(event.uri) + } + + 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) + } + } + + /** + * Validate the picked plugin file's name off the main thread (querying a `content://` URI's + * display name is a `ContentResolver` IPC call) and route to install confirmation or an error. + */ + private fun handleFileSelected(uri: Uri) { + viewModelScope.launch { + val isSupported = + withContext(Dispatchers.IO) { + uri.getFileName(contentResolver).endsWith(PLUGIN_EXTENSION, ignoreCase = true) + } + + if (isSupported) { + _uiEffect.trySend(PluginManagerUiEffect.ShowInstallConfirmation(uri)) + } else { + _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_unsupported_plugin_file)) + } + } + } + + /** + * 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/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt b/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt index a542147b4b..f80e32ee76 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt @@ -1,32 +1,35 @@ package com.itsaky.androidide.utils +import android.content.ContentResolver import android.content.Context import android.net.Uri import android.provider.OpenableColumns import android.util.Log -fun Uri.getFileName(context: Context): String { - val unknownFileLabel = "Unknown File" - if (scheme == "content") { - try { - context.contentResolver.query(this, null, null, null, null)?.use { cursor -> - if (cursor.moveToFirst()) { - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (nameIndex >= 0) { - return cursor.getString(nameIndex) ?: unknownFileLabel - } - } - } - } catch (e: SecurityException) { - Log.w("UriExtensions", "SecurityException while reading URI: ${scheme}://${authority}", e) - } catch (e: Exception) { - Log.w("UriExtensions", "Unexpected error while reading URI: ${scheme}://${authority}", e) - } +fun Uri.getFileName(context: Context): String = getFileName(context.contentResolver) - return unknownFileLabel - } +fun Uri.getFileName(contentResolver: ContentResolver): String { + val unknownFileLabel = "Unknown File" + if (scheme == "content") { + try { + contentResolver.query(this, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex >= 0) { + return cursor.getString(nameIndex) ?: unknownFileLabel + } + } + } + } catch (e: SecurityException) { + Log.w("UriExtensions", "SecurityException while reading URI: $scheme://$authority", e) + } catch (e: Exception) { + Log.w("UriExtensions", "Unexpected error while reading URI: $scheme://$authority", e) + } - val fallbackName = path?.substringAfterLast('/') ?: unknownFileLabel - val decodedName = Uri.decode(fallbackName) - return decodedName.ifBlank { unknownFileLabel } -} \ No newline at end of file + return unknownFileLabel + } + + val fallbackName = path?.substringAfterLast('/') ?: unknownFileLabel + val decodedName = Uri.decode(fallbackName) + return decodedName.ifBlank { unknownFileLabel } +} From 75d5f448474a75dfead64b3bdb489ee5f241f61c Mon Sep 17 00:00:00 2001 From: yaturner Date: Wed, 5 Aug 2026 10:58:53 -0700 Subject: [PATCH 12/16] ADFA-4928: Fix manager UI correctness/accessibility issues Address CodeRabbit review feedback on PR #1627: - Avoid double system-bar insets by zeroing ManagerScreen's Scaffold contentWindowInsets, since the activity's root already applies them - Fix the back button's TalkBack announcement (was "Cancel") with a dedicated cd_navigate_back string - Wire long-press tooltips to the discover-plugins action and install FAB - Always show Uninstall for a listed plugin, even when it failed to load, so a broken plugin has a recovery action - Move the detail-row "label: value" format into a string resource so translators control ordering/punctuation - Only treat a template card as clickable when it bundles more than one template, instead of always exposing tap/press semantics - Use an Android plurals resource for the template count string instead of a fixed "templates" string - Buffer TemplateManagerViewModel's uiEffect channel and use send() instead of trySend() so effects aren't dropped before a collector is ready --- .../androidide/ui/compose/ManagerScreen.kt | 22 ++++++++++++++++++- .../ui/compose/plugins/PluginListItem.kt | 14 ++++++------ .../compose/plugins/PluginManagerDialogs.kt | 2 +- .../ui/compose/templates/TemplateListItem.kt | 22 ++++++++++++++----- .../viewmodels/TemplateManagerViewModel.kt | 22 +++++++++---------- resources/src/main/res/values/strings.xml | 7 +++++- 6 files changed, 63 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt index 86a8de26fb..c90dfba492 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt @@ -2,7 +2,9 @@ package com.itsaky.androidide.ui.compose import androidx.activity.ComponentActivity import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager @@ -19,9 +21,13 @@ import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import com.itsaky.androidide.R +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.ui.compose.plugins.PluginManagerContent import com.itsaky.androidide.ui.compose.templates.TemplateManagerScreen import com.itsaky.androidide.ui.models.PluginManagerUiEvent @@ -54,9 +60,15 @@ fun ManagerScreen( ) { val pagerState = rememberPagerState(pageCount = { 2 }) val coroutineScope = rememberCoroutineScope() + val rootView = LocalView.current + + fun showTooltip() { + TooltipManager.showIdeCategoryTooltip(activity, rootView, TooltipTag.PLUGIN_MANAGER) + } Scaffold( modifier = modifier, + contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { TopAppBar( title = { Text(stringResource(R.string.title_manager)) }, @@ -64,7 +76,7 @@ fun ManagerScreen( IconButton(onClick = { activity.onBackPressedDispatcher.onBackPressed() }) { Icon( painter = painterResource(R.drawable.ic_back), - contentDescription = stringResource(android.R.string.cancel), + contentDescription = stringResource(R.string.cd_navigate_back), ) } }, @@ -74,6 +86,10 @@ fun ManagerScreen( onClick = { UrlManager.openUrl(activity.getString(R.string.url_discover_plugins), null, activity) }, + modifier = + Modifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { showTooltip() }) + }, ) { Icon( painter = painterResource(R.drawable.ic_download), @@ -88,6 +104,10 @@ fun ManagerScreen( if (pagerState.currentPage == TAB_PLUGINS) { FloatingActionButton( onClick = { pluginViewModel.onEvent(PluginManagerUiEvent.OpenFilePicker) }, + modifier = + Modifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { showTooltip() }) + }, ) { Icon( painter = painterResource(R.drawable.ic_add), diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt index 2d202bc693..9048a1f8c6 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginListItem.kt @@ -133,14 +133,14 @@ fun PluginListItem( }, ) } - DropdownMenuItem( - text = { Text(stringResource(R.string.uninstall_plugin)) }, - onClick = { - menuExpanded = false - onUninstall() - }, - ) } + DropdownMenuItem( + text = { Text(stringResource(R.string.uninstall_plugin)) }, + onClick = { + menuExpanded = false + onUninstall() + }, + ) DropdownMenuItem( text = { Text(stringResource(R.string.plugin_details)) }, onClick = { diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt index 7deb1f358a..bdd1d39a9f 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/plugins/PluginManagerDialogs.kt @@ -127,5 +127,5 @@ private fun DetailRow( label: String, value: String, ) { - Text("$label: $value") + Text(stringResource(R.string.label_value, label, value)) } diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt index 1688063e08..e4af5c4458 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/templates/TemplateListItem.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.ui.compose.templates import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -21,8 +22,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.colorResource import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -58,10 +61,15 @@ fun TemplateListItem( modifier = modifier .fillMaxWidth() - .combinedClickable( - onClick = { if (item.hasMultipleTemplates) onViewTemplates() }, - onLongClick = onLongPressTooltip, - ), + .let { cardModifier -> + if (item.hasMultipleTemplates) { + cardModifier.combinedClickable(onClick = onViewTemplates, onLongClick = onLongPressTooltip) + } else { + cardModifier.pointerInput(Unit) { + detectTapGestures(onLongPress = { onLongPressTooltip() }) + } + } + }, ) { Row(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.weight(1f)) { @@ -81,7 +89,11 @@ fun TemplateListItem( if (item.hasMultipleTemplates) { Text( - stringResource(R.string.template_contains_count, item.templates.size), + pluralStringResource( + R.plurals.template_contains_count, + item.templates.size, + item.templates.size, + ), style = MaterialTheme.typography.labelSmall, modifier = Modifier.clickable(onClick = onViewTemplates), ) diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt index 76b6a7a207..34a0f4e3f2 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/TemplateManagerViewModel.kt @@ -30,7 +30,7 @@ class TemplateManagerViewModel( private val _uiState = MutableStateFlow(TemplateManagerUiState()) val uiState: StateFlow = _uiState.asStateFlow() - private val _uiEffect = Channel() + private val _uiEffect = Channel(Channel.BUFFERED) val uiEffect = _uiEffect.receiveAsFlow() init { @@ -60,7 +60,7 @@ class TemplateManagerViewModel( }.onFailure { exception -> Log.e(TAG, "Failed to load template files", exception) _uiState.update { it.copy(isLoading = false) } - _uiEffect.trySend( + _uiEffect.send( TemplateManagerUiEffect.ShowError( R.string.msg_template_load_failed, listOf(exception.message ?: ""), @@ -76,11 +76,11 @@ class TemplateManagerViewModel( .installTemplate(item) .onSuccess { Log.d(TAG, "Template installed successfully: ${item.name}") - _uiEffect.trySend(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_installed)) + _uiEffect.send(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_installed)) loadTemplates() }.onFailure { exception -> Log.e(TAG, "Failed to install template: ${item.name}", exception) - _uiEffect.trySend( + _uiEffect.send( TemplateManagerUiEffect.ShowError( R.string.msg_template_install_failed, listOf(exception.message ?: ""), @@ -96,11 +96,11 @@ class TemplateManagerViewModel( .uninstallTemplate(item) .onSuccess { Log.d(TAG, "Template uninstalled successfully: ${item.name}") - _uiEffect.trySend(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_uninstalled)) + _uiEffect.send(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_uninstalled)) loadTemplates() }.onFailure { exception -> Log.e(TAG, "Failed to uninstall template: ${item.name}", exception) - _uiEffect.trySend( + _uiEffect.send( TemplateManagerUiEffect.ShowError( R.string.msg_template_uninstall_failed, listOf(exception.message ?: ""), @@ -112,7 +112,7 @@ class TemplateManagerViewModel( private fun showDeleteConfirmation(item: CgtFileItem) { viewModelScope.launch { - _uiEffect.trySend(TemplateManagerUiEffect.ShowDeleteConfirmation(item)) + _uiEffect.send(TemplateManagerUiEffect.ShowDeleteConfirmation(item)) } } @@ -123,11 +123,11 @@ class TemplateManagerViewModel( .deleteDownloadFile(item) .onSuccess { Log.d(TAG, "Deleted download file: ${item.name}") - _uiEffect.trySend(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_deleted)) + _uiEffect.send(TemplateManagerUiEffect.ShowSuccess(R.string.msg_template_deleted)) loadTemplates() }.onFailure { exception -> Log.e(TAG, "Failed to delete download file: ${item.name}", exception) - _uiEffect.trySend( + _uiEffect.send( TemplateManagerUiEffect.ShowError( R.string.msg_template_delete_failed, listOf(exception.message ?: ""), @@ -139,13 +139,13 @@ class TemplateManagerViewModel( private fun showTemplateDetails(item: CgtFileItem) { viewModelScope.launch { - _uiEffect.trySend(TemplateManagerUiEffect.ShowTemplateDetails(item)) + _uiEffect.send(TemplateManagerUiEffect.ShowTemplateDetails(item)) } } private fun showTemplateList(item: CgtFileItem) { viewModelScope.launch { - _uiEffect.trySend(TemplateManagerUiEffect.ShowTemplateList(item)) + _uiEffect.send(TemplateManagerUiEffect.ShowTemplateList(item)) } } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index f2522874b1..7d3cfbd23a 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1005,6 +1005,7 @@ Redo Delete Add + Navigate back Search Error Warning @@ -1087,6 +1088,7 @@ Author Description Min IDE Version + %1$s: %2$s %1$s: %2$s Failed to load templates: %1$s @@ -1267,7 +1269,10 @@ Bundled From plugin Imported - Contains %1$d templates + + Contains %1$d template + Contains %1$d templates + Install Uninstall View templates From 98344ccd6d5213c2564060b0d8c87db5ef38ca71 Mon Sep 17 00:00:00 2001 From: yaturner Date: Wed, 5 Aug 2026 10:59:05 -0700 Subject: [PATCH 13/16] ADFA-4928: Add KDoc for TemplateMetadata/CgtFileItem Address CodeRabbit review feedback on PR #1627: document the model contracts, including the meaning of installed/provenance and the one-archive-to-many-templates relationship. Co-Authored-By: Claude Sonnet 5 --- .../templates/manager/models/CgtFileItem.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt b/app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt index 84b293ca53..e620764d9c 100644 --- a/app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt +++ b/app/src/main/java/com/itsaky/androidide/templates/manager/models/CgtFileItem.kt @@ -2,6 +2,11 @@ package com.itsaky.androidide.templates.manager.models import java.io.File +/** + * One `/template/template.json` entry parsed out of a `.cgt` archive. A single `.cgt` + * file can bundle more than one of these (see [CgtFileItem.templates]) - e.g. a plugin's + * archive offering several related project templates. + */ data class TemplateMetadata( val name: String, val description: String, @@ -27,6 +32,12 @@ enum class TemplateProvenance { USER, } +/** + * One `.cgt` file discovered on disk, backing a single card in the Templates tab. [templates] + * holds every template the archive bundles (see [TemplateMetadata]); [installed] is true when + * [file] lives in `Environment.TEMPLATES_DIR` (the store Gradle reads templates from) rather + * than the Downloads folder, and [provenance] (see [TemplateProvenance]) says who put it there. + */ data class CgtFileItem( val file: File, val name: String, From 46231d63a482de0cc58c96dff06b848c2b2c96e9 Mon Sep 17 00:00:00 2001 From: yaturner Date: Wed, 5 Aug 2026 10:59:19 -0700 Subject: [PATCH 14/16] ADFA-4928: Enable JUnit Jupiter for app unit tests Address CodeRabbit review feedback on PR #1627 (matches the JUnit Jupiter + Truth strategy ARCHITECTURE.md already documents for unit tests, which the app module hadn't wired up yet): - Run app unit tests on the JUnit Platform, with the vintage engine so existing JUnit 4/Robolectric tests keep running unchanged - Migrate CgtFileItemTest (no Robolectric dependency) to org.junit.jupiter.api.Test with Truth assertions - Keep CgtTemplateReaderTest on JUnit 4/RobolectricTestRunner (no built-in Jupiter integration) but switch its assertions to Truth Verified all 22 app unit test classes still run under :app:testV8DebugUnitTest with 0 failures. --- app/build.gradle.kts | 7 +++ .../manager/models/CgtFileItemTest.kt | 44 +++++++++---------- .../manager/parsing/CgtTemplateReaderTest.kt | 27 ++++++------ gradle/libs.versions.toml | 1 + 4 files changed, 42 insertions(+), 37 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 60a56c0acb..b001aee8fb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -94,6 +94,9 @@ android { // Skip TreeSitter native library loading in tests it.systemProperty("java.library.path", System.getProperty("java.library.path")) it.systemProperty("androidide.test.mode", "true") + // JUnit Platform, so JUnit Jupiter tests run; the vintage engine dependency + // below keeps existing JUnit 4/Robolectric tests running unchanged. + it.useJUnitPlatform() } } } @@ -341,6 +344,10 @@ dependencies { testImplementation(projects.testing.unit) testImplementation(libs.core.tests.anroidx.arch) + testImplementation(libs.tests.junit.jupiter) + testRuntimeOnly(libs.tests.junit.platformLauncher) + // Keeps existing JUnit 4/Robolectric tests running under the JUnit Platform. + testRuntimeOnly(libs.tests.junit.vintageEngine) androidTestImplementation(projects.common) androidTestImplementation(projects.testing.android) { exclude(group = "com.google.protobuf", module = "protobuf-lite") diff --git a/app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt b/app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt index 1345f06717..d95c291705 100644 --- a/app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt +++ b/app/src/test/java/com/itsaky/androidide/templates/manager/models/CgtFileItemTest.kt @@ -1,9 +1,7 @@ package com.itsaky.androidide.templates.manager.models -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test import java.io.File class CgtFileItemTest { @@ -21,55 +19,55 @@ class CgtFileItemTest { @Test fun displayName_stripsCgtExtension() { - assertEquals("core", item("core.cgt").displayName) - assertEquals("core", item("core.CGT").displayName) // case-insensitive + assertThat(item("core.cgt").displayName).isEqualTo("core") + assertThat(item("core.CGT").displayName).isEqualTo("core") // case-insensitive } @Test fun displayName_leavesOtherNamesUnchanged() { - assertEquals("core", item("core").displayName) - assertEquals("my.template.cgt".dropLast(4), item("my.template.cgt").displayName) - assertEquals("readme.txt", item("readme.txt").displayName) + assertThat(item("core").displayName).isEqualTo("core") + assertThat(item("my.template.cgt").displayName).isEqualTo("my.template.cgt".dropLast(4)) + assertThat(item("readme.txt").displayName).isEqualTo("readme.txt") } @Test fun primaryTemplate_isFirst_orEmptyFallback() { val a = TemplateMetadata("A", "da", "1.0") val b = TemplateMetadata("B", "db", "2.0") - assertEquals(a, item("x.cgt", listOf(a, b)).primaryTemplate) + assertThat(item("x.cgt", listOf(a, b)).primaryTemplate).isEqualTo(a) val empty = item("x.cgt", emptyList()).primaryTemplate - assertEquals("", empty.name) - assertEquals("", empty.version) + assertThat(empty.name).isEmpty() + assertThat(empty.version).isEmpty() } @Test fun hasMultipleTemplates_reflectsCount() { - assertFalse(item("x.cgt", listOf(TemplateMetadata("A", "", "1"))).hasMultipleTemplates) - assertTrue( + assertThat(item("x.cgt", listOf(TemplateMetadata("A", "", "1"))).hasMultipleTemplates).isFalse() + assertThat( item("x.cgt", listOf(TemplateMetadata("A", "", "1"), TemplateMetadata("B", "", "1"))) .hasMultipleTemplates, - ) - assertFalse(item("x.cgt", emptyList()).hasMultipleTemplates) + ).isTrue() + assertThat(item("x.cgt", emptyList()).hasMultipleTemplates).isFalse() } @Test fun versionLabel_prefixesWithV() { - assertEquals("v1.0", versionLabel("1.0")) - assertEquals("v0.1", versionLabel("0.1")) - assertEquals("v1.2.3", versionLabel("1.2.3")) + assertThat(versionLabel("1.0")).isEqualTo("v1.0") + assertThat(versionLabel("0.1")).isEqualTo("v0.1") + assertThat(versionLabel("1.2.3")).isEqualTo("v1.2.3") } @Test fun versionLabel_truncatesMoreThanThreeSegments() { // Only the first three dot-separated segments are kept (matches the host Plugin Manager). - assertEquals("v1.0.0-build...", versionLabel("1.0.0-build.20260101")) - assertEquals("v1.2.3...", versionLabel("1.2.3.4")) + assertThat(versionLabel("1.0.0-build.20260101")).isEqualTo("v1.0.0-build...") + assertThat(versionLabel("1.2.3.4")).isEqualTo("v1.2.3...") } @Test fun versionLabel_blankBecomesEmpty() { - assertEquals("", versionLabel("")) - assertEquals("", versionLabel(" ")) + assertThat(versionLabel("")).isEmpty() + assertThat(versionLabel(" ")).isEmpty() } } diff --git a/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt b/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt index 3b4ca446f3..1b33d587a4 100644 --- a/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt +++ b/app/src/test/java/com/itsaky/androidide/templates/manager/parsing/CgtTemplateReaderTest.kt @@ -1,7 +1,6 @@ package com.itsaky.androidide.templates.manager.parsing -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue +import com.google.common.truth.Truth.assertThat import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -37,11 +36,11 @@ class CgtTemplateReaderTest { ), ) val result = CgtTemplateReader.readTemplates(input) - assertEquals(1, result.size) - assertEquals("Basic Activity", result[0].name) - assertEquals("Creates a new basic activity", result[0].description) - assertEquals("0.1", result[0].version) - assertTrue(result[0].optionalTags.isEmpty()) + assertThat(result).hasSize(1) + assertThat(result[0].name).isEqualTo("Basic Activity") + assertThat(result[0].description).isEqualTo("Creates a new basic activity") + assertThat(result[0].version).isEqualTo("0.1") + assertThat(result[0].optionalTags).isEmpty() } @Test @@ -55,8 +54,8 @@ class CgtTemplateReaderTest { ), ) val result = CgtTemplateReader.readTemplates(input) - assertEquals(2, result.size) - assertEquals(setOf("Empty", "Login"), result.map { it.name }.toSet()) + assertThat(result).hasSize(2) + assertThat(result.map { it.name }.toSet()).isEqualTo(setOf("Empty", "Login")) } @Test @@ -78,7 +77,7 @@ class CgtTemplateReaderTest { ) val tags = CgtTemplateReader.readTemplates(input).single().optionalTags // org.json key iteration order isn't guaranteed, so compare as a set. - assertEquals(setOf("language (LANGUAGE)", "minsdk (MIN_SDK)"), tags.toSet()) + assertThat(tags.toSet()).isEqualTo(setOf("language (LANGUAGE)", "minsdk (MIN_SDK)")) } @Test @@ -97,8 +96,8 @@ class CgtTemplateReaderTest { ), ) val template = CgtTemplateReader.readTemplates(input).single() - assertEquals("Basic Activity", template.name) - assertEquals(listOf("language (LANGUAGE)"), template.optionalTags) + assertThat(template.name).isEqualTo("Basic Activity") + assertThat(template.optionalTags).isEqualTo(listOf("language (LANGUAGE)")) } @Test @@ -110,12 +109,12 @@ class CgtTemplateReaderTest { """{"name":"T","description":"d","version":"1.0","parameters":{"optional":{"flag":{}}}}""", ), ) - assertEquals(listOf("flag"), CgtTemplateReader.readTemplates(input).single().optionalTags) + assertThat(CgtTemplateReader.readTemplates(input).single().optionalTags).isEqualTo(listOf("flag")) } @Test fun returnsEmptyWhenNoTemplateJson() { val input = cgt(mapOf("pkg/readme.txt" to "hello", "pkg/template/other.json" to "{}")) - assertTrue(CgtTemplateReader.readTemplates(input).isEmpty()) + assertThat(CgtTemplateReader.readTemplates(input)).isEmpty() } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f02f1c33fb..169910bc45 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -270,6 +270,7 @@ git-jgit = { module = "org.eclipse.jgit:org.eclipse.jgit", version = "6.8.0.2023 tests-junit = { module = "junit:junit", version = "4.13.2" } tests-junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } tests-junit-platformLauncher = { module = "org.junit.platform:junit-platform-launcher" } +tests-junit-vintageEngine = { module = "org.junit.vintage:junit-vintage-engine", version.ref = "junit-jupiter" } core-tests-anroidx-arch = { module = "androidx.arch.core:core-testing", version.ref = "anroidx-test-core" } tests-google-truth = { module = "com.google.truth:truth", version = "1.4.1" } tests-robolectric = { module = "org.robolectric:robolectric", version = "4.11.1" } From 0c9406cb3a0666edafa0f3980d029bf07a8eb999 Mon Sep 17 00:00:00 2001 From: yaturner Date: Wed, 5 Aug 2026 11:14:59 -0700 Subject: [PATCH 15/16] ADFA-4928: Fix regressions CodeRabbit's re-review found in prior fixes Address CodeRabbit follow-up review feedback on PR #1627: - Only run the cachedFilesDir warmup eagerly in onCreate() when credential-protected storage is already unlocked - the default Context.getFilesDir() throws during Direct Boot. When locked, warm it instead from CredentialProtectedApplicationLoader.load(), which only proceeds once that storage is confirmed accessible. - Base FileImage's inSampleSize loop on the larger image dimension instead of requiring both dimensions to exceed the target, so a wide-but-short (or tall-but-narrow) image still gets downsampled - Log FileImage's swallowed SecurityException/OutOfMemoryError icon-load failures via a throttled SLF4J warning, without logging the file path - Buffer PluginManagerViewModel's uiEffect channel and use send() instead of trySend(), same fix already applied to TemplateManagerViewModel, so effects (e.g. the new ShowInstallConfirmation) aren't dropped - Narrow UriExtensions.getFileName's second catch to SecurityException/ IllegalArgumentException instead of blanket Exception, so unexpected ContentResolver failures surface instead of being silently mislabeled as "Unknown File" (and then downstream as an unsupported plugin file); switch its logging to a class-scoped SLF4J logger --- .../CredentialProtectedApplicationLoader.kt | 4 ++ .../itsaky/androidide/app/IDEApplication.kt | 10 +++- .../androidide/ui/compose/common/FileImage.kt | 24 +++++++-- .../viewmodels/PluginManagerViewModel.kt | 50 +++++++++---------- .../itsaky/androidide/utils/UriExtensions.kt | 11 ++-- 5 files changed, 65 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt index f9bef8288b..d1c67fc8fb 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -79,6 +79,10 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { return } + // Storage is confirmed accessible here, so it's safe to warm IDEApplication.cachedFilesDir + // now for devices that were still locked (Direct Boot) when onCreate() ran its own warmup. + withContext(Dispatchers.IO) { IDEApplication.cachedFilesDir } + if (!_isLoaded.compareAndSet(false, true)) { // Another call already claimed initialization (e.g. a concurrent retry after // user unlock); avoid running the rest of this method twice. diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index b4d8f7326c..be014622f3 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -199,8 +199,14 @@ class IDEApplication : // Warm cachedFilesDir on an IO thread before Koin starts, so pluginModule/templateModule // (resolved on the main thread on first navigation to the Extensions Manager) can never // race the disk read - see cachedFilesDir's doc. The disk access itself runs off-main; - // this only blocks onCreate() waiting for that fast, one-time result. - runBlocking(Dispatchers.IO) { cachedFilesDir } + // this only blocks onCreate() waiting for that fast, one-time result. Only safe when + // credential-protected storage is already unlocked - instance.filesDir uses the default + // (credential-protected) Context and throws during Direct Boot. When locked, the warmup + // instead runs from CredentialProtectedApplicationLoader.load(), which only proceeds once + // that storage is confirmed accessible. + if (isUserUnlocked) { + runBlocking(Dispatchers.IO) { cachedFilesDir } + } ensureKoinStarted() diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt index 0ca5739f13..25e5bd00e4 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/common/FileImage.kt @@ -17,7 +17,25 @@ import androidx.compose.ui.unit.dp import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory import java.io.File +import java.util.concurrent.atomic.AtomicLong + +private val log = LoggerFactory.getLogger("FileImage") +private const val LOG_THROTTLE_MILLIS = 5_000L +private val lastIconLoadFailureLoggedAt = AtomicLong(0L) + +/** Logs at most once every [LOG_THROTTLE_MILLIS] - a bad icon file can recompose repeatedly. */ +private fun logIconLoadFailureThrottled( + message: String, + cause: Throwable, +) { + val now = System.currentTimeMillis() + val last = lastIconLoadFailureLoggedAt.get() + if (now - last >= LOG_THROTTLE_MILLIS && lastIconLoadFailureLoggedAt.compareAndSet(last, now)) { + log.warn(message, cause) + } +} /** * Renders [file] as an image, decoded off the main thread, falling back to [placeholder] while @@ -47,8 +65,10 @@ fun FileImage( } catch (e: CancellationException) { throw e } catch (e: SecurityException) { + logIconLoadFailureThrottled("Denied access while loading an icon", e) null } catch (e: OutOfMemoryError) { + logIconLoadFailureThrottled("Out of memory while loading an icon", e) null } } @@ -83,9 +103,7 @@ private fun decodeBounded( if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null var inSampleSize = 1 - while (bounds.outWidth / (inSampleSize * 2) >= maxDimensionPx && - bounds.outHeight / (inSampleSize * 2) >= maxDimensionPx - ) { + while (maxOf(bounds.outWidth, bounds.outHeight) / (inSampleSize * 2) >= maxDimensionPx) { inSampleSize *= 2 } 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 bdf9b134a4..525578292e 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -53,7 +53,7 @@ class PluginManagerViewModel( val uiState: StateFlow = _uiState.asStateFlow() // Channel for one-time UI effects - private val _uiEffect = Channel() + private val _uiEffect = Channel(Channel.BUFFERED) val uiEffect = _uiEffect.receiveAsFlow() // Current operation tracking @@ -143,7 +143,7 @@ class PluginManagerViewModel( _uiState.update { it.copy(isLoading = false) } - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_load_failed, listOf(exception.message ?: ""), @@ -170,15 +170,15 @@ class PluginManagerViewModel( .onSuccess { success -> if (success) { Log.d(TAG, "Plugin enabled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_enabled)) + _uiEffect.send(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)) + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_enable_failed)) } }.onFailure { exception -> Log.e(TAG, "Error enabling plugin: $pluginId", exception) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_enable_error, listOf(exception.message ?: ""), @@ -202,15 +202,15 @@ class PluginManagerViewModel( .onSuccess { success -> if (success) { Log.d(TAG, "Plugin disabled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_disabled)) + _uiEffect.send(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)) + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_disable_failed)) } }.onFailure { exception -> Log.e(TAG, "Error disabling plugin: $pluginId", exception) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_disable_error, listOf(exception.message ?: ""), @@ -229,7 +229,7 @@ class PluginManagerViewModel( val plugin = _uiState.value.plugins.find { it.metadata.id == pluginId } if (plugin != null) { viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.ShowUninstallConfirmation(plugin)) + _uiEffect.send(PluginManagerUiEffect.ShowUninstallConfirmation(plugin)) } } } @@ -246,16 +246,16 @@ class PluginManagerViewModel( .onSuccess { success -> if (success) { Log.d(TAG, "Plugin uninstalled successfully: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_uninstalled)) + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_uninstalled)) loadPlugins() - _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) + _uiEffect.send(PluginManagerUiEffect.ShowRestartPrompt) } else { Log.w(TAG, "Failed to uninstall plugin: $pluginId") - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_uninstall_failed)) + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_uninstall_failed)) } }.onFailure { exception -> Log.e(TAG, "Error uninstalling plugin: $pluginId", exception) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_uninstall_error, listOf(exception.message ?: ""), @@ -310,16 +310,16 @@ class PluginManagerViewModel( .installPluginFromFile(tempFile) .onSuccess { Log.d(TAG, "Plugin installed successfully") - _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) + _uiEffect.send(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed)) loadPlugins() - _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt) + _uiEffect.send(PluginManagerUiEffect.ShowRestartPrompt) if (deleteSourceAfterInstall) { deleteSourceDocument(uri) } }.onFailure { exception -> Log.e(TAG, "Failed to install plugin", exception) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_install_failed, listOf(exception.message ?: ""), @@ -328,7 +328,7 @@ class PluginManagerViewModel( } } catch (exception: Exception) { Log.e(TAG, "Error installing plugin from URI", exception) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError( R.string.msg_plugin_install_failed, listOf(exception.message ?: ""), @@ -356,7 +356,7 @@ class PluginManagerViewModel( 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)) + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file)) return true } @@ -383,7 +383,7 @@ class PluginManagerViewModel( deleteSourceAfterInstall = deleteSourceAfterInstall, ) } - _uiEffect.trySend(effect) + _uiEffect.send(effect) return true } @@ -392,13 +392,13 @@ class PluginManagerViewModel( try { val deleted = DocumentsContract.deleteDocument(contentResolver, uri) if (!deleted) { - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), ) } } catch (e: Exception) { Log.w(TAG, "Failed to delete source document", e) - _uiEffect.trySend( + _uiEffect.send( PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed), ) } @@ -410,7 +410,7 @@ class PluginManagerViewModel( */ private fun openFilePicker() { viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.OpenFilePicker) + _uiEffect.send(PluginManagerUiEffect.OpenFilePicker) } } @@ -426,9 +426,9 @@ class PluginManagerViewModel( } if (isSupported) { - _uiEffect.trySend(PluginManagerUiEffect.ShowInstallConfirmation(uri)) + _uiEffect.send(PluginManagerUiEffect.ShowInstallConfirmation(uri)) } else { - _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_unsupported_plugin_file)) + _uiEffect.send(PluginManagerUiEffect.ShowError(R.string.msg_unsupported_plugin_file)) } } } @@ -438,7 +438,7 @@ class PluginManagerViewModel( */ private fun showPluginDetails(plugin: PluginInfo) { viewModelScope.launch { - _uiEffect.trySend(PluginManagerUiEffect.ShowPluginDetails(plugin)) + _uiEffect.send(PluginManagerUiEffect.ShowPluginDetails(plugin)) } } diff --git a/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt b/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt index f80e32ee76..729f2cad1b 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/UriExtensions.kt @@ -4,7 +4,9 @@ import android.content.ContentResolver import android.content.Context import android.net.Uri import android.provider.OpenableColumns -import android.util.Log +import org.slf4j.LoggerFactory + +private val log = LoggerFactory.getLogger("UriExtensions") fun Uri.getFileName(context: Context): String = getFileName(context.contentResolver) @@ -21,9 +23,10 @@ fun Uri.getFileName(contentResolver: ContentResolver): String { } } } catch (e: SecurityException) { - Log.w("UriExtensions", "SecurityException while reading URI: $scheme://$authority", e) - } catch (e: Exception) { - Log.w("UriExtensions", "Unexpected error while reading URI: $scheme://$authority", e) + log.warn("Denied access while reading URI: {}://{}", scheme, authority, e) + } catch (e: IllegalArgumentException) { + // No registered provider for this URI, or the provider rejected the query args. + log.warn("No provider could resolve URI: {}://{}", scheme, authority, e) } return unknownFileLabel From 2b7be22373072005dad4b9b9c0258c7a150cf0a3 Mon Sep 17 00:00:00 2001 From: yaturner Date: Wed, 5 Aug 2026 12:47:55 -0700 Subject: [PATCH 16/16] ADFA-4928: Address human review comments from hal-eisen-adfa - Disable the install FAB while a plugin install is in flight, so a second tap can't start a concurrent installPlugin() coroutine. The Compose ManagerScreen replaced the old Activity, which disabled the FAB via binding.fabInstallPlugin.isEnabled = !state.isInstalling; nothing carried that behavior over. - Fix PLUGIN_AUTHORING.md pointers left dangling by the PluginListAdapter.kt -> PluginListItem.kt/FileImage.kt migration. The delete-failure-handling and cachedFilesDir warmup comments from the same review were already addressed by prior commits on this branch; verified against current HEAD, no further changes needed. --- .../androidide/ui/compose/ManagerScreen.kt | 21 +++++++++++++++---- docs/PLUGIN_AUTHORING.md | 6 +++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt index c90dfba492..46bdf7f4bd 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/compose/ManagerScreen.kt @@ -19,12 +19,15 @@ import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.itsaky.androidide.R import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag @@ -36,6 +39,9 @@ import com.itsaky.androidide.viewmodels.PluginManagerViewModel import com.itsaky.androidide.viewmodels.TemplateManagerViewModel import kotlinx.coroutines.launch +/** Matches Material's conventional disabled-content alpha; M3 has no ContentAlpha equivalent. */ +private const val DISABLED_ALPHA = 0.38f + private const val TAB_PLUGINS = 0 private const val TAB_TEMPLATES = 1 @@ -61,6 +67,7 @@ fun ManagerScreen( val pagerState = rememberPagerState(pageCount = { 2 }) val coroutineScope = rememberCoroutineScope() val rootView = LocalView.current + val pluginUiState by pluginViewModel.uiState.collectAsStateWithLifecycle() fun showTooltip() { TooltipManager.showIdeCategoryTooltip(activity, rootView, TooltipTag.PLUGIN_MANAGER) @@ -103,11 +110,17 @@ fun ManagerScreen( floatingActionButton = { if (pagerState.currentPage == TAB_PLUGINS) { FloatingActionButton( - onClick = { pluginViewModel.onEvent(PluginManagerUiEvent.OpenFilePicker) }, + onClick = { + if (!pluginUiState.isInstalling) { + pluginViewModel.onEvent(PluginManagerUiEvent.OpenFilePicker) + } + }, modifier = - Modifier.pointerInput(Unit) { - detectTapGestures(onLongPress = { showTooltip() }) - }, + Modifier + .alpha(if (pluginUiState.isInstalling) DISABLED_ALPHA else 1f) + .pointerInput(Unit) { + detectTapGestures(onLongPress = { showTooltip() }) + }, ) { Icon( painter = painterResource(R.drawable.ic_add), diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 3f23efbed2..4f9548c548 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -111,7 +111,7 @@ use the JSON form (see `PluginManifest.kt`). ## Theme-aware icons The plugin manager renders a different icon based on whether the system -is in light or dark mode (`PluginListAdapter.kt:61`). To opt in, ship +is in light or dark mode (`PluginListItem.kt:69-70`). To opt in, ship two raster icons in your plugin and point at them from the manifest. ### Where the files go @@ -139,7 +139,7 @@ manifest matches the path the loader will find. - **JPEG** **Not supported:** raw SVG, Android vector drawable XML (compiled or -not). Icons are decoded with Glide (`PluginListAdapter.kt:69`), which +not). Icons are decoded with `BitmapFactory` (`FileImage.kt:102`), which handles raster formats only. Convert SVG sources to PNG yourself before bundling. @@ -251,7 +251,7 @@ manifest value (use `assets/icon_day.png`, not `/assets/icon_day.png`). **Wrong icon shows for the current theme** -The selection happens in `PluginListAdapter.kt:61` via +The selection happens in `PluginListItem.kt:69-70` via `isSystemInDarkMode()`. Verify your device is actually in the theme you expect (system Settings → Display). Also verify both files extracted to the device: