diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 3ecaccc691..dfcf41e4f1 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -147,7 +147,7 @@ data class PluginManagerUiState(
sealed class PluginManagerUiEvent {
object LoadPlugins : PluginManagerUiEvent()
data class EnablePlugin(val pluginId: String) : PluginManagerUiEvent()
- data class InstallPlugin(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent()
+ data class InstallPlugin(val source: PluginInstallSource, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent()
// ...
}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 2f4fdf7ddc..21c52a5fc2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -34,6 +34,7 @@ plugins {
// Sentry gradle plugin; the SDK it wires up reports to our GlitchTip backend.
alias(libs.plugins.sentry)
alias(libs.plugins.google.services)
+ alias(libs.plugins.kotlin.compose)
}
fun propOrEnv(name: String): String =
@@ -102,6 +103,10 @@ android {
generateLocaleConfig = true
}
+ buildFeatures {
+ compose = true
+ }
+
sourceSets {
getByName("androidTest") {
manifest.srcFile("src/androidTest/AndroidManifest.xml")
@@ -241,6 +246,17 @@ dependencies {
// Git
implementation(libs.git.jgit)
+ // Compose (ADR 0009 - new IDE dialogs/screens are Compose)
+ implementation(platform(libs.compose.bom))
+ implementation(libs.compose.runtime)
+ implementation(libs.compose.ui)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.material3)
+ implementation(libs.compose.activity)
+ implementation(libs.compose.lifecycle.runtime)
+ implementation(libs.compose.ui.tooling.preview)
+ debugImplementation(libs.compose.ui.tooling)
+
// AndroidX
implementation(libs.androidx.splashscreen)
implementation(libs.androidx.annotation)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cf216f8b6c..521b7867a1 100755
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -104,9 +104,19 @@
+
+ android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|uiMode|locale|fontScale|density|keyboard|keyboardHidden|navigation" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{
+ apkInstallationViewModel.installApk(
+ context = this,
+ apk = file,
+ launchInDebugMode = false,
+ )
+ }
- override fun EditorHandlerActivity.doAction(data: ActionData): Boolean {
- val file = editorViewModel.getCurrentFile() ?: return false
- when (file.extension.lowercase()) {
- "apk" -> apkInstallationViewModel.installApk(
- context = this, apk = file, launchInDebugMode = false
- )
- "cgp" -> lifecycleScope.launch {
- val repo = GlobalContext.get().get()
- repo.installPluginFromFile(file)
- .onSuccess {
- flashSuccess(getString(R.string.msg_plugin_installed_restart))
- DialogUtils.showRestartPrompt(this@doAction)
- }
- .onFailure { e ->
- flashError(getString(R.string.msg_plugin_install_failed, e.message))
- }
- }
- }
- return true
- }
+ PLUGIN_ARCHIVE_EXTENSION -> {
+ lifecycleScope.launch {
+ val repo = GlobalContext.get().get()
+ repo
+ .installPluginFromFile(file)
+ .onSuccess {
+ flashSuccess(getString(R.string.msg_plugin_installed_restart))
+ DialogUtils.showRestartPrompt(this@doAction)
+ }.onFailure { e ->
+ flashError(getString(R.string.msg_plugin_install_failed, e.message))
+ }
+ }
+ }
+ }
+ return true
+ }
}
diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt
new file mode 100644
index 0000000000..9ff9b806cc
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallActivity.kt
@@ -0,0 +1,55 @@
+package com.itsaky.androidide.activities
+
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import androidx.compose.ui.platform.ComposeView
+import com.itsaky.androidide.app.IDEActivity
+import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel
+import org.koin.androidx.viewmodel.ext.android.viewModel
+
+/**
+ * Trampoline activity that receives a `.cgp`/`.cgt` file opened from outside the app (e.g. an
+ * email attachment), prompts to install it, and finishes - it has no content of its own beyond
+ * the dialogs [ExternalFileInstallScreen] shows.
+ *
+ * `singleTask` (manifest) + [onNewIntent] collapse a rapid double-tap on the same external file
+ * into this one instance/ViewModel, where [ExternalFileInstallViewModel]'s `receivedUriGate`
+ * already dedupes by Uri - without it, `standard` launch mode would spin up a second
+ * Activity+ViewModel pair minting an independent temp file, which `PluginManagerViewModel`'s
+ * path-based dedup guard can't recognize as the same source.
+ */
+class ExternalFileInstallActivity : IDEActivity() {
+ private val viewModel: ExternalFileInstallViewModel by viewModel()
+
+ override fun bindLayout(): View =
+ ComposeView(this).apply {
+ setContent { ExternalFileInstallScreen(viewModel) }
+ }
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ handleIntent()
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ handleIntent()
+ }
+
+ private fun handleIntent() {
+ val uri = intent?.data
+ if (uri == null) {
+ finish()
+ return
+ }
+
+ // No savedInstanceState guard here: onReceived() is idempotent per ViewModel instance
+ // (a rotation, or a re-delivered intent via onNewIntent, keeps the same instance, so this
+ // is a no-op there), and calling it unconditionally means a process-death-recreated
+ // instance - which starts fresh and would otherwise never see the restored intent's data -
+ // still gets processed.
+ viewModel.onReceived(uri)
+ }
+}
diff --git a/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt
new file mode 100644
index 0000000000..16f86299d2
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/activities/ExternalFileInstallScreen.kt
@@ -0,0 +1,310 @@
+package com.itsaky.androidide.activities
+
+import android.app.Activity
+import android.content.Intent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.itsaky.androidide.R
+import com.itsaky.androidide.floating.ui.FloatingTheme
+import com.itsaky.androidide.idetooltips.TooltipTag
+import com.itsaky.androidide.repositories.TemplateCollectionRepository
+import com.itsaky.androidide.ui.compose.longPressTooltip
+import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect
+import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent
+import com.itsaky.androidide.utils.flashErrorAwaitShown
+import com.itsaky.androidide.utils.flashSuccessAwaitShown
+import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel
+import java.io.File
+
+private sealed interface DialogUiState {
+ object None : DialogUiState
+
+ data class InstallConfirm(
+ val info: TemplateCollectionRepository.CollectionInfo,
+ val tempFile: File,
+ val suggestedBaseName: String,
+ ) : DialogUiState
+
+ data class NameConflict(
+ val existingName: String,
+ val info: TemplateCollectionRepository.CollectionInfo,
+ val tempFile: File,
+ ) : DialogUiState
+
+ data class Rename(
+ val existingName: String,
+ val tempFile: File,
+ ) : DialogUiState
+}
+
+@Composable
+fun ExternalFileInstallScreen(viewModel: ExternalFileInstallViewModel) {
+ val context = LocalContext.current
+ var dialogState by remember { mutableStateOf(DialogUiState.None) }
+ val isInstalling by viewModel.isInstalling.collectAsStateWithLifecycle()
+
+ LaunchedEffect(viewModel) {
+ viewModel.uiEffect.collect { effect ->
+ when (effect) {
+ is ExternalFileInstallUiEffect.ForwardToPluginManager -> {
+ context.startActivity(
+ Intent(context, PluginManagerActivity::class.java)
+ // A Plugin Manager instance may already be running/backgrounded (e.g.
+ // the user had it open, then opened a .cgp attachment) - these flags
+ // reuse that instance via onNewIntent() instead of stacking a second
+ // one on top of it.
+ .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ .putExtra(PluginManagerActivity.EXTRA_PENDING_INSTALL_FILE_PATH, effect.filePath),
+ )
+ (context as? Activity)?.finish()
+ }
+
+ is ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation -> {
+ dialogState =
+ DialogUiState.InstallConfirm(effect.info, effect.tempFile, effect.suggestedBaseName)
+ }
+
+ is ExternalFileInstallUiEffect.ShowTemplateNameConflict -> {
+ dialogState = DialogUiState.NameConflict(effect.existingName, effect.info, effect.tempFile)
+ }
+
+ is ExternalFileInstallUiEffect.ShowError -> {
+ // Deliberately doesn't touch dialogState: on an install failure the ViewModel
+ // sends ShowError without a following Finish, so whichever dialog is open
+ // (install-confirm / name-conflict / rename) stays open for the user to retry.
+ // Awaits the bar's entrance animation instead of returning immediately: this
+ // suspends the collect{} loop above, so a Finish effect buffered right after
+ // this one (see sendErrorAndFinish()) isn't processed - and doesn't tear the
+ // window down - until the message has actually finished appearing.
+ flashErrorAwaitShown(context.getString(effect.messageResId, *effect.formatArgs.toTypedArray()))
+ }
+
+ is ExternalFileInstallUiEffect.ShowSuccess -> {
+ flashSuccessAwaitShown(context.getString(effect.messageResId, *effect.formatArgs.toTypedArray()))
+ }
+
+ is ExternalFileInstallUiEffect.Finish -> {
+ (context as? Activity)?.finish()
+ }
+ }
+ }
+ }
+
+ FloatingTheme {
+ when (val state = dialogState) {
+ is DialogUiState.InstallConfirm -> {
+ InstallConfirmationDialog(
+ state = state,
+ installEnabled = !isInstalling,
+ onInstall = {
+ viewModel.onEvent(
+ ExternalFileInstallUiEvent.ConfirmTemplateInstall(
+ tempFile = state.tempFile,
+ targetBaseName = state.suggestedBaseName,
+ overwrite = false,
+ ),
+ )
+ },
+ onDismiss = {
+ viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile))
+ },
+ )
+ }
+
+ is DialogUiState.NameConflict -> {
+ NameConflictDialog(
+ state = state,
+ installEnabled = !isInstalling,
+ onOverwrite = {
+ viewModel.onEvent(
+ ExternalFileInstallUiEvent.ConfirmTemplateInstall(
+ tempFile = state.tempFile,
+ targetBaseName = state.existingName,
+ overwrite = true,
+ ),
+ )
+ },
+ onRename = { dialogState = DialogUiState.Rename(state.existingName, state.tempFile) },
+ onDismiss = {
+ viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile))
+ },
+ )
+ }
+
+ is DialogUiState.Rename -> {
+ RenameDialog(
+ state = state,
+ installEnabled = !isInstalling,
+ suggestName = viewModel::suggestUniqueBaseName,
+ onConfirm = { newName ->
+ viewModel.onEvent(
+ ExternalFileInstallUiEvent.ConfirmTemplateInstall(
+ tempFile = state.tempFile,
+ targetBaseName = viewModel.sanitizeBaseName(newName),
+ overwrite = false,
+ ),
+ )
+ },
+ onDismiss = {
+ viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(state.tempFile))
+ },
+ )
+ }
+
+ DialogUiState.None -> {
+ Unit
+ }
+ }
+ }
+}
+
+/** Comma-joined display list of a collection's template names, shared by both confirm dialogs. */
+private fun TemplateCollectionRepository.CollectionInfo.displayTemplateNames(): String = templateNames.joinToString(", ")
+
+@Composable
+private fun InstallConfirmationDialog(
+ state: DialogUiState.InstallConfirm,
+ installEnabled: Boolean,
+ onInstall: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ // Gated on installEnabled (== !isInstalling): once Install is tapped, the ViewModel
+ // starts copying/replacing tempFile on viewModelScope - dismissing here would race
+ // IgnoreTemplateInstall's own delete of that same file against the in-progress install.
+ onDismissRequest = { if (installEnabled) onDismiss() },
+ title = {
+ Text(
+ stringResource(R.string.title_install_template_collection),
+ modifier = Modifier.longPressTooltip(TooltipTag.EXTERNAL_FILE_INSTALL),
+ )
+ },
+ text = {
+ Text(
+ stringResource(
+ R.string.msg_template_install_confirm,
+ state.suggestedBaseName,
+ state.info.displayTemplateNames(),
+ ),
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = onInstall, enabled = installEnabled) { Text(stringResource(R.string.btn_install)) }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) }
+ },
+ )
+}
+
+@Composable
+private fun NameConflictDialog(
+ state: DialogUiState.NameConflict,
+ installEnabled: Boolean,
+ onOverwrite: () -> Unit,
+ onRename: () -> Unit,
+ onDismiss: () -> Unit,
+) {
+ AlertDialog(
+ onDismissRequest = { if (installEnabled) onDismiss() },
+ title = {
+ Text(
+ stringResource(R.string.title_template_already_installed),
+ modifier = Modifier.longPressTooltip(TooltipTag.EXTERNAL_FILE_INSTALL),
+ )
+ },
+ text = {
+ Text(
+ stringResource(
+ R.string.msg_template_name_conflict,
+ state.existingName,
+ state.info.displayTemplateNames(),
+ ),
+ )
+ },
+ // Three actions don't fit in AlertDialog's default single-row confirm/dismiss layout
+ // without wrapping awkwardly (e.g. two buttons stacked oddly against the third) - stack
+ // them vertically instead, right-aligned, all within the confirmButton slot (dismissButton
+ // left unset).
+ confirmButton = {
+ Column(horizontalAlignment = Alignment.End) {
+ TextButton(onClick = onOverwrite, enabled = installEnabled) { Text(stringResource(R.string.btn_overwrite)) }
+ TextButton(onClick = onRename, enabled = installEnabled) { Text(stringResource(R.string.btn_rename_and_install)) }
+ TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) }
+ }
+ },
+ )
+}
+
+@Composable
+private fun RenameDialog(
+ state: DialogUiState.Rename,
+ installEnabled: Boolean,
+ suggestName: suspend (String) -> String,
+ onConfirm: (String) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ var name by remember { mutableStateOf(TextFieldValue(state.existingName)) }
+ var userEdited by remember { mutableStateOf(false) }
+ var suggestionReady by remember { mutableStateOf(false) }
+ val currentSuggestName by rememberUpdatedState(suggestName)
+
+ LaunchedEffect(state.existingName) {
+ val suggested = currentSuggestName(state.existingName)
+ // Only apply the suggestion if the user hasn't already started typing their own name -
+ // this resolves asynchronously and must not clobber in-progress input.
+ if (!userEdited) {
+ name = TextFieldValue(suggested, selection = TextRange(suggested.length))
+ }
+ suggestionReady = true
+ }
+
+ AlertDialog(
+ onDismissRequest = { if (installEnabled) onDismiss() },
+ title = {
+ Text(
+ stringResource(R.string.btn_rename_and_install),
+ modifier = Modifier.longPressTooltip(TooltipTag.EXTERNAL_FILE_INSTALL),
+ )
+ },
+ text = {
+ OutlinedTextField(
+ value = name,
+ onValueChange = {
+ name = it
+ userEdited = true
+ },
+ label = { Text(stringResource(R.string.hint_new_template_collection_name)) },
+ singleLine = true,
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(name.text) },
+ enabled = installEnabled && suggestionReady && name.text.isNotBlank(),
+ ) {
+ Text(stringResource(R.string.btn_install))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss, enabled = installEnabled) { Text(stringResource(android.R.string.cancel)) }
+ },
+ )
+}
diff --git a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt
index a3129fbffb..08fec8b4da 100644
--- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt
+++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt
@@ -27,6 +27,7 @@ import com.itsaky.androidide.databinding.ActivityPluginManagerBinding
import com.itsaky.androidide.idetooltips.TooltipManager
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.plugins.PluginInfo
+import com.itsaky.androidide.ui.models.PluginInstallSource
import com.itsaky.androidide.ui.models.PluginManagerUiEffect
import com.itsaky.androidide.ui.models.PluginManagerUiEvent
import com.itsaky.androidide.utils.DURATION_INDEFINITE
@@ -39,13 +40,25 @@ import com.itsaky.androidide.utils.flashbarBuilder
import com.itsaky.androidide.utils.getFileName
import com.itsaky.androidide.utils.showOnUiThread
import com.itsaky.androidide.viewmodels.PluginManagerViewModel
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION
import org.koin.androidx.viewmodel.ext.android.viewModel
+import java.io.File
class PluginManagerActivity : EdgeToEdgeIDEActivity() {
companion object {
private const val TAG = "PluginManagerActivity"
- private const val PLUGIN_EXTENSION = ".cgp"
+ private const val PLUGIN_EXTENSION = ".$PLUGIN_ARCHIVE_EXTENSION"
+
+ /**
+ * Absolute path of a `.cgp` file forwarded from
+ * [com.itsaky.androidide.activities.ExternalFileInstallActivity] - a plain path rather than
+ * a `content://` Uri, since both activities run in this same process and already trust
+ * filesDir paths, letting the install skip a redundant ContentResolver copy.
+ */
+ const val EXTRA_PENDING_INSTALL_FILE_PATH = "pending_install_file_path"
}
@Suppress("ktlint:standard:backing-property-naming")
@@ -75,7 +88,7 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() {
return@let
}
- showInstallConfirmation(it)
+ showInstallConfirmation(PluginInstallSource.ContentUri(it))
}
}
@@ -103,6 +116,8 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() {
setupTooltipLongPress()
setupFeedbackButton()
observeViewModel()
+
+ handlePendingInstallExtra()
} catch (e: Exception) {
// Log the error and finish the activity if something goes wrong
e.printStackTrace()
@@ -111,6 +126,40 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() {
}
}
+ // ForwardToPluginManager's launch Intent carries FLAG_ACTIVITY_CLEAR_TOP/SINGLE_TOP so a
+ // forwarded install reuses an already-running instance instead of stacking a duplicate one -
+ // which routes the extra through onNewIntent() rather than a fresh onCreate().
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ handlePendingInstallExtra()
+ }
+
+ // No savedInstanceState guard: markPendingInstallHandled() is the idempotency check, scoped
+ // to the ViewModel instance rather than the Activity's recreation reason - it survives
+ // rotation (skips a duplicate dialog there) but resets on process death (a fresh ViewModel is
+ // created), so a process-death-recreated instance still shows the dialog instead of silently
+ // dropping the forwarded install. The intent's extra itself is preserved across both cases by
+ // the OS.
+ private fun handlePendingInstallExtra() {
+ intent.getStringExtra(EXTRA_PENDING_INSTALL_FILE_PATH)?.let { filePath ->
+ if (viewModel.markPendingInstallHandled(filePath)) {
+ lifecycleScope.launch {
+ val file = File(filePath)
+ val exists = withContext(Dispatchers.IO) { file.exists() }
+ if (exists) {
+ showInstallConfirmation(PluginInstallSource.LocalFile(file))
+ } else {
+ // Can legitimately happen if InstallTempFiles' stale-file sweep (or an
+ // earlier failed cleanup) removed the temp file before this dialog ever
+ // got a chance to show it - a clear message beats a generic install error.
+ flashError(getString(R.string.msg_plugin_file_not_found))
+ }
+ }
+ }
+ }
+ }
+
override fun onResume() {
super.onResume()
feedbackButtonManager?.loadFabPosition()
@@ -300,16 +349,32 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() {
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)
+ /**
+ * For a [PluginInstallSource.LocalFile] (a `.cgp` forwarded from [ExternalFileInstallActivity]),
+ * [source] is our own hidden temp copy, not a file the user picked - there's no checkbox to
+ * offer (deletion isn't optional) and no source worth keeping on decline/cancel either, so
+ * both the negative button and back-press/tap-outside route to [PluginManagerUiEvent.CancelPendingInstall].
+ * One shared dialog builder for both cases so a future button/copy change can't be applied to
+ * only one branch and silently reintroduce a leaked-temp-file bug in the other.
+ */
+ private fun showInstallConfirmation(source: PluginInstallSource) {
+ val forceDeleteSource = source is PluginInstallSource.LocalFile
+ val dialogView = if (forceDeleteSource) null else layoutInflater.inflate(R.layout.dialog_install_plugin, null)
+ val deleteCheckBox = dialogView?.findViewById(R.id.checkbox_delete_source)
+ val onCancel = {
+ if (forceDeleteSource) {
+ viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(source))
+ }
+ }
MaterialAlertDialogBuilder(this)
.setTitle(R.string.title_install_plugin)
- .setView(dialogView)
+ .apply { dialogView?.let { setView(it) } }
.setPositiveButton(R.string.btn_install) { _, _ ->
- viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(uri, deleteCheckBox.isChecked))
- }.setNegativeButton(android.R.string.cancel, null)
+ val deleteSourceAfterInstall = if (forceDeleteSource) true else deleteCheckBox?.isChecked == true
+ viewModel.onEvent(PluginManagerUiEvent.InstallPlugin(source, deleteSourceAfterInstall))
+ }.setNegativeButton(android.R.string.cancel) { _, _ -> onCancel() }
+ .setOnCancelListener { onCancel() }
.show()
}
@@ -325,10 +390,15 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() {
),
).setPositiveButton(R.string.replace) { _, _ ->
viewModel.onEvent(
- PluginManagerUiEvent.ConfirmOverwrite(effect.uri, effect.deleteSourceAfterInstall),
+ PluginManagerUiEvent.ConfirmOverwrite(effect.source, effect.deleteSourceAfterInstall),
)
- }.setNegativeButton(android.R.string.cancel, null)
- .show()
+ }.setNegativeButton(android.R.string.cancel) { _, _ ->
+ viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(effect.source))
+ }.setOnCancelListener {
+ // Same reasoning as showInstallConfirmation()'s onCancelListener: back-press must
+ // route through CancelPendingInstall too, or a forwarded source's temp file leaks.
+ viewModel.onEvent(PluginManagerUiEvent.CancelPendingInstall(effect.source))
+ }.show()
}
private fun showUninstallConfirmation(plugin: PluginInfo) {
diff --git a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt
index 0152cc285b..fc3a7b0e0a 100644
--- a/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt
+++ b/app/src/main/java/com/itsaky/androidide/di/PluginModule.kt
@@ -3,6 +3,9 @@ package com.itsaky.androidide.di
import com.itsaky.androidide.app.IDEApplication
import com.itsaky.androidide.repositories.PluginRepository
import com.itsaky.androidide.repositories.PluginRepositoryImpl
+import com.itsaky.androidide.repositories.TemplateCollectionRepository
+import com.itsaky.androidide.repositories.TemplateCollectionRepositoryImpl
+import com.itsaky.androidide.viewmodels.ExternalFileInstallViewModel
import com.itsaky.androidide.viewmodels.PluginManagerViewModel
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
@@ -12,22 +15,36 @@ import java.io.File
/**
* Koin module for plugin-related dependencies
*/
-val pluginModule = module {
+val pluginModule =
+ module {
- // Repository
- single {
- PluginRepositoryImpl(
- pluginManagerProvider = { IDEApplication.getPluginManager() },
- pluginsDir = File(androidContext().filesDir, "plugins")
- )
- }
+ // Repository
+ single {
+ PluginRepositoryImpl(
+ pluginManagerProvider = { IDEApplication.getPluginManager() },
+ pluginsDir = File(androidContext().filesDir, "plugins"),
+ )
+ }
- // ViewModel
- viewModel {
- PluginManagerViewModel(
- pluginRepository = get(),
- contentResolver = androidContext().contentResolver,
- filesDir = androidContext().filesDir
- )
- }
-}
\ No newline at end of file
+ single {
+ TemplateCollectionRepositoryImpl()
+ }
+
+ // ViewModel
+ viewModel {
+ PluginManagerViewModel(
+ pluginRepository = get(),
+ contentResolver = androidContext().contentResolver,
+ filesDir = androidContext().filesDir,
+ )
+ }
+
+ viewModel {
+ ExternalFileInstallViewModel(
+ pluginRepository = get(),
+ templateCollectionRepository = get(),
+ contentResolver = androidContext().contentResolver,
+ filesDir = androidContext().filesDir,
+ )
+ }
+ }
diff --git a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt
index 3d27258387..1fa28ebf90 100644
--- a/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt
+++ b/app/src/main/java/com/itsaky/androidide/dnd/DragAndDropExtensions.kt
@@ -7,50 +7,49 @@ import android.content.Context
import android.net.Uri
import android.view.DragEvent
import androidx.core.net.toUri
+import com.itsaky.androidide.utils.fileProviderAuthority
/**
* Checks if the [DragEvent] contains any URIs that can be imported into the project.
*/
fun DragEvent.hasImportableContent(context: Context): Boolean {
- if (localState != null) return false
-
- return when (action) {
- DragEvent.ACTION_DROP -> {
- val clip = clipData ?: return false
- (0 until clip.itemCount).any { index ->
- clip.getItemAt(index).toImportableExternalUris(context).isNotEmpty()
- }
- }
-
- else -> clipDescription?.hasImportableMimeType() == true
- }
+ if (localState != null) return false
+
+ return when (action) {
+ DragEvent.ACTION_DROP -> {
+ val clip = clipData ?: return false
+ (0 until clip.itemCount).any { index ->
+ clip.getItemAt(index).toImportableExternalUris(context).isNotEmpty()
+ }
+ }
+
+ else -> {
+ clipDescription?.hasImportableMimeType() == true
+ }
+ }
}
/**
* Resolves the [ClipData.Item] to a list of external [Uri]s, ignoring internal application URIs.
*/
-fun ClipData.Item.toImportableExternalUris(context: Context): List {
- return toExternalUris().filterNot { it.isInternalDragUri(context) }
-}
+fun ClipData.Item.toImportableExternalUris(context: Context): List = toExternalUris().filterNot { it.isInternalDragUri(context) }
-private fun Uri.isInternalDragUri(context: Context): Boolean {
- return authority == "${context.packageName}.providers.fileprovider"
-}
+private fun Uri.isInternalDragUri(context: Context): Boolean = authority == context.fileProviderAuthority()
private fun ClipData.Item.toExternalUris(): List {
- uri?.let { return listOf(it) }
+ uri?.let { return listOf(it) }
- val textContent = text?.toString() ?: return emptyList()
+ val textContent = text?.toString() ?: return emptyList()
- return textContent.lineSequence()
- .map { it.trim() }
- .map { it.toUri() }
- .filter { it.scheme == ContentResolver.SCHEME_CONTENT || it.scheme == ContentResolver.SCHEME_FILE }
- .toList()
+ return textContent
+ .lineSequence()
+ .map { it.trim() }
+ .map { it.toUri() }
+ .filter { it.scheme == ContentResolver.SCHEME_CONTENT || it.scheme == ContentResolver.SCHEME_FILE }
+ .toList()
}
-private fun ClipDescription.hasImportableMimeType(): Boolean {
- return hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST) ||
- hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) ||
- hasMimeType("*/*")
-}
+private fun ClipDescription.hasImportableMimeType(): Boolean =
+ hasMimeType(ClipDescription.MIMETYPE_TEXT_URILIST) ||
+ hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) ||
+ hasMimeType("*/*")
diff --git a/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt b/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt
index cb30e0c79f..7498137eb0 100644
--- a/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt
+++ b/app/src/main/java/com/itsaky/androidide/dnd/FileDragStarter.kt
@@ -5,89 +5,97 @@ import android.content.Context
import android.net.Uri
import android.view.View
import android.webkit.MimeTypeMap
-import androidx.core.content.FileProvider
import androidx.core.view.ViewCompat
+import com.itsaky.androidide.utils.fileProviderUriFor
import java.io.File
import java.util.Locale
sealed interface FileDragResult {
- data object Started : FileDragResult
- data class Failed(val error: FileDragError) : FileDragResult
+ data object Started : FileDragResult
+
+ data class Failed(
+ val error: FileDragError,
+ ) : FileDragResult
}
sealed interface FileDragError {
- data object FileNotFound : FileDragError
- data object NotAFile : FileDragError
- data object SystemRejected : FileDragError
- data class Exception(val throwable: Throwable) : FileDragError
+ data object FileNotFound : FileDragError
+
+ data object NotAFile : FileDragError
+
+ data object SystemRejected : FileDragError
+
+ data class Exception(
+ val throwable: Throwable,
+ ) : FileDragError
}
class FileDragStarter(
- private val context: Context,
+ private val context: Context,
) {
+ fun startDrag(
+ sourceView: View,
+ file: File,
+ ): FileDragResult {
+ if (!file.exists()) {
+ return FileDragResult.Failed(FileDragError.FileNotFound)
+ }
- fun startDrag(sourceView: View, file: File): FileDragResult {
- if (!file.exists()) {
- return FileDragResult.Failed(FileDragError.FileNotFound)
- }
-
- if (!file.isFile) {
- return FileDragResult.Failed(FileDragError.NotAFile)
- }
-
- return runCatching {
- val contentUri = buildContentUri(file)
- val mimeType = resolveMimeType(file)
- val clipData = buildClipData(file, contentUri, mimeType)
- val dragShadow = View.DragShadowBuilder(sourceView)
+ if (!file.isFile) {
+ return FileDragResult.Failed(FileDragError.NotAFile)
+ }
- ViewCompat.startDragAndDrop(
- sourceView,
- clipData,
- dragShadow,
- null,
- DRAG_FLAGS,
- )
- }.fold(
- onSuccess = { started ->
- if (started) FileDragResult.Started
- else FileDragResult.Failed(FileDragError.SystemRejected)
- },
- onFailure = { throwable ->
- FileDragResult.Failed(FileDragError.Exception(throwable))
- },
- )
- }
+ return runCatching {
+ val contentUri = buildContentUri(file)
+ val mimeType = resolveMimeType(file)
+ val clipData = buildClipData(file, contentUri, mimeType)
+ val dragShadow = View.DragShadowBuilder(sourceView)
- private fun buildContentUri(file: File): Uri {
- return FileProvider.getUriForFile(context, fileProviderAuthority, file)
- }
+ ViewCompat.startDragAndDrop(
+ sourceView,
+ clipData,
+ dragShadow,
+ null,
+ DRAG_FLAGS,
+ )
+ }.fold(
+ onSuccess = { started ->
+ if (started) {
+ FileDragResult.Started
+ } else {
+ FileDragResult.Failed(FileDragError.SystemRejected)
+ }
+ },
+ onFailure = { throwable ->
+ FileDragResult.Failed(FileDragError.Exception(throwable))
+ },
+ )
+ }
- private fun resolveMimeType(file: File): String {
- val extension = file.extension.lowercase(Locale.ROOT)
- return MimeTypeMap.getSingleton()
- .getMimeTypeFromExtension(extension)
- ?: DEFAULT_MIME_TYPE
- }
+ private fun buildContentUri(file: File): Uri = context.fileProviderUriFor(file)
- private fun buildClipData(
- file: File,
- contentUri: Uri,
- mimeType: String,
- ): ClipData {
- return ClipData(
- file.name,
- arrayOf(mimeType),
- ClipData.Item(contentUri),
- )
- }
+ private fun resolveMimeType(file: File): String {
+ val extension = file.extension.lowercase(Locale.ROOT)
+ return MimeTypeMap
+ .getSingleton()
+ .getMimeTypeFromExtension(extension)
+ ?: DEFAULT_MIME_TYPE
+ }
- private val fileProviderAuthority: String
- get() = "${context.packageName}.providers.fileprovider"
+ private fun buildClipData(
+ file: File,
+ contentUri: Uri,
+ mimeType: String,
+ ): ClipData =
+ ClipData(
+ file.name,
+ arrayOf(mimeType),
+ ClipData.Item(contentUri),
+ )
- private companion object {
- private const val DEFAULT_MIME_TYPE = "application/octet-stream"
- private const val DRAG_FLAGS =
- View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ
- }
+ private companion object {
+ private const val DEFAULT_MIME_TYPE = "application/octet-stream"
+ private const val DRAG_FLAGS =
+ View.DRAG_FLAG_GLOBAL or View.DRAG_FLAG_GLOBAL_URI_READ
+ }
}
diff --git a/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt b/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt
index 82309f0c9f..690aa71373 100644
--- a/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt
+++ b/app/src/main/java/com/itsaky/androidide/handlers/FileTreeActionHandler.kt
@@ -26,6 +26,7 @@ import com.itsaky.androidide.actions.ActionMenu
import com.itsaky.androidide.actions.ActionsRegistry
import com.itsaky.androidide.actions.internal.DefaultActionsRegistry
import com.itsaky.androidide.activities.editor.EditorHandlerActivity
+import com.itsaky.androidide.app.IDEApplication
import com.itsaky.androidide.eventbus.events.filetree.FileClickEvent
import com.itsaky.androidide.eventbus.events.filetree.FileLongClickEvent
import com.itsaky.androidide.events.CollapseTreeNodeRequestEvent
@@ -34,12 +35,13 @@ import com.itsaky.androidide.events.FileContextMenuItemClickEvent
import com.itsaky.androidide.events.FileContextMenuItemLongClickEvent
import com.itsaky.androidide.fragments.sheets.OptionsListFragment
import com.itsaky.androidide.idetooltips.TooltipManager
-import com.itsaky.androidide.app.IDEApplication
import com.itsaky.androidide.models.SheetOption
import com.itsaky.androidide.plugins.extensions.FileTabMenuItem
import com.itsaky.androidide.utils.flashError
import com.unnamed.b.atv.model.TreeNode
import kotlinx.coroutines.launch
+import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION
+import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode.MAIN
@@ -52,153 +54,157 @@ import java.io.File
*/
@Suppress("unused")
class FileTreeActionHandler : BaseEventHandler() {
-
- private var lastHeld: TreeNode? = null
-
- companion object {
-
- const val TAG_FILE_OPTIONS_FRAGMENT = "file_options_fragment"
- const val MB_10: Long = 10 * 1024 * 1024
- }
-
- @Subscribe(threadMode = MAIN)
- fun onFileClicked(event: FileClickEvent) {
- if (!checkIsEditorActivity(event)) {
- logCannotHandle(event)
- return
- }
-
- if (event.file.isDirectory) {
- return
- }
-
- val context = event[Context::class.java]!! as EditorHandlerActivity
- context.binding.editorDrawerLayout.closeDrawer(GravityCompat.START)
-
- val isArchive = event.file.extension.lowercase() in setOf("apk", "cgp", "zip")
- if (!isArchive && MB_10 < event.file.length()) {
- flashError("File is too big!")
- log.warn(
- "Cannot open {} as it is too big. File size: {} bytes", event.file, event.file.length())
- return
- }
-
- context.lifecycleScope.launch {
- context.openFile(event.file)
- }
- }
-
- @Subscribe(threadMode = MAIN)
- fun onFileLongClicked(event: FileLongClickEvent) {
- if (!checkIsEditorActivity(event)) {
- logCannotHandle(event)
- return
- }
-
- this.lastHeld = event[TreeNode::class.java]
- val context = event[Context::class.java]!! as EditorHandlerActivity
- createFileOptionsFragment(context, event.file)
- .show(context.supportFragmentManager, TAG_FILE_OPTIONS_FRAGMENT)
- }
-
- private fun createFileOptionsFragment(
- context: EditorHandlerActivity,
- file: File
- ): OptionsListFragment {
- val fragment = OptionsListFragment()
- val registry = ActionsRegistry.getInstance()
- val actions = registry.getActions(EDITOR_FILE_TREE)
- val data = ActionData.create(context)
- data.apply {
- put(File::class.java, file)
- put(TreeNode::class.java, lastHeld)
- }
-
- for (action in actions.values) {
-
- check(action !is ActionMenu) { "File tree actions do not support action menus" }
-
- action.prepare(data)
- if (!action.enabled || !action.visible) {
- continue
- }
-
- fragment.addOption(
- SheetOption(action.id, action.icon, action.label, file).apply { this.extra = data }
- )
- }
-
- IDEApplication.getPluginManager()
- ?.getFileTabMenuItems(file)
- ?.filter { it.isEnabled && it.isVisible }
- ?.forEach { item ->
- fragment.addOption(SheetOption("plugin.file.${item.id}", null, item.title, item))
- }
-
- return fragment
- }
-
- @Subscribe(threadMode = MAIN)
- internal fun onFileOptionClicked(event: FileContextMenuItemClickEvent) {
- val option = event.option
- if (option.extra is FileTabMenuItem) {
- try { (option.extra as FileTabMenuItem).action() } catch (e: Exception) { log.error("Plugin file menu action failed", e) }
- return
- }
- if (option.extra !is ActionData) {
- return
- }
-
- val data = option.extra!! as ActionData
- val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry
- val action = registry.findAction(EDITOR_FILE_TREE, option.id)
-
- checkNotNull(action) {
- "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'"
- }
-
- registry.executeAction(action, data)
- }
-
- @Subscribe(threadMode = MAIN)
- internal fun onFileOptionLongClicked(event: FileContextMenuItemLongClickEvent) {
- val option = event.option
- val actionData = option.extra
- if (actionData !is ActionData) {
- return
- }
-
- val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry
- val action = registry.findAction(EDITOR_FILE_TREE, option.id)
-
- checkNotNull(action) {
- "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'"
- }
- val tag = action.retrieveTooltipTag(actionData.get(File::class.java)?.isDirectory == true)
- tag.isNotEmpty() || return
- val activity = event[Context::class.java] as? EditorHandlerActivity
- activity?.let { act ->
- TooltipManager.showIdeCategoryTooltip(
- context = act,
- anchorView = act.window.decorView,
- tag = tag,
- )
- }
- }
-
- private fun requestExpandHeldNode() {
- requestExpandNode(lastHeld!!)
- }
-
- private fun requestCollapseHeldNode() {
- requestCollapseNode(lastHeld!!, true)
- }
-
- private fun requestExpandNode(node: TreeNode) {
- EventBus.getDefault().post(ExpandTreeNodeRequestEvent(node))
- }
-
- private fun requestCollapseNode(node: TreeNode, includeSubnodes: Boolean) {
- EventBus.getDefault().post(CollapseTreeNodeRequestEvent(node, includeSubnodes))
- }
+ private var lastHeld: TreeNode? = null
+
+ companion object {
+ const val TAG_FILE_OPTIONS_FRAGMENT = "file_options_fragment"
+ const val MB_10: Long = 10 * 1024 * 1024
+ }
+
+ @Subscribe(threadMode = MAIN)
+ fun onFileClicked(event: FileClickEvent) {
+ if (!checkIsEditorActivity(event)) {
+ logCannotHandle(event)
+ return
+ }
+
+ if (event.file.isDirectory) {
+ return
+ }
+
+ val context = event[Context::class.java]!! as EditorHandlerActivity
+ context.binding.editorDrawerLayout.closeDrawer(GravityCompat.START)
+
+ val isArchive = event.file.extension.lowercase() in setOf("apk", PLUGIN_ARCHIVE_EXTENSION, TEMPLATE_ARCHIVE_EXTENSION, "zip")
+ if (!isArchive && MB_10 < event.file.length()) {
+ flashError("File is too big!")
+ log.warn("Cannot open {} as it is too big. File size: {} bytes", event.file, event.file.length())
+ return
+ }
+
+ context.lifecycleScope.launch {
+ context.openFile(event.file)
+ }
+ }
+
+ @Subscribe(threadMode = MAIN)
+ fun onFileLongClicked(event: FileLongClickEvent) {
+ if (!checkIsEditorActivity(event)) {
+ logCannotHandle(event)
+ return
+ }
+
+ this.lastHeld = event[TreeNode::class.java]
+ val context = event[Context::class.java]!! as EditorHandlerActivity
+ createFileOptionsFragment(context, event.file)
+ .show(context.supportFragmentManager, TAG_FILE_OPTIONS_FRAGMENT)
+ }
+
+ private fun createFileOptionsFragment(
+ context: EditorHandlerActivity,
+ file: File,
+ ): OptionsListFragment {
+ val fragment = OptionsListFragment()
+ val registry = ActionsRegistry.getInstance()
+ val actions = registry.getActions(EDITOR_FILE_TREE)
+ val data = ActionData.create(context)
+ data.apply {
+ put(File::class.java, file)
+ put(TreeNode::class.java, lastHeld)
+ }
+
+ for (action in actions.values) {
+ check(action !is ActionMenu) { "File tree actions do not support action menus" }
+
+ action.prepare(data)
+ if (!action.enabled || !action.visible) {
+ continue
+ }
+
+ fragment.addOption(
+ SheetOption(action.id, action.icon, action.label, file).apply { this.extra = data },
+ )
+ }
+
+ IDEApplication
+ .getPluginManager()
+ ?.getFileTabMenuItems(file)
+ ?.filter { it.isEnabled && it.isVisible }
+ ?.forEach { item ->
+ fragment.addOption(SheetOption("plugin.file.${item.id}", null, item.title, item))
+ }
+
+ return fragment
+ }
+
+ @Subscribe(threadMode = MAIN)
+ internal fun onFileOptionClicked(event: FileContextMenuItemClickEvent) {
+ val option = event.option
+ if (option.extra is FileTabMenuItem) {
+ try {
+ (option.extra as FileTabMenuItem).action()
+ } catch (e: Exception) {
+ log.error("Plugin file menu action failed", e)
+ }
+ return
+ }
+ if (option.extra !is ActionData) {
+ return
+ }
+
+ val data = option.extra!! as ActionData
+ val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry
+ val action = registry.findAction(EDITOR_FILE_TREE, option.id)
+
+ checkNotNull(action) {
+ "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'"
+ }
+
+ registry.executeAction(action, data)
+ }
+
+ @Subscribe(threadMode = MAIN)
+ internal fun onFileOptionLongClicked(event: FileContextMenuItemLongClickEvent) {
+ val option = event.option
+ val actionData = option.extra
+ if (actionData !is ActionData) {
+ return
+ }
+
+ val registry = ActionsRegistry.getInstance() as DefaultActionsRegistry
+ val action = registry.findAction(EDITOR_FILE_TREE, option.id)
+
+ checkNotNull(action) {
+ "Invalid FileContextMenuItemClickEvent received. No action item registered with id '${option.id}'"
+ }
+ val tag = action.retrieveTooltipTag(actionData.get(File::class.java)?.isDirectory == true)
+ tag.isNotEmpty() || return
+ val activity = event[Context::class.java] as? EditorHandlerActivity
+ activity?.let { act ->
+ TooltipManager.showIdeCategoryTooltip(
+ context = act,
+ anchorView = act.window.decorView,
+ tag = tag,
+ )
+ }
+ }
+
+ private fun requestExpandHeldNode() {
+ requestExpandNode(lastHeld!!)
+ }
+
+ private fun requestCollapseHeldNode() {
+ requestCollapseNode(lastHeld!!, true)
+ }
+
+ private fun requestExpandNode(node: TreeNode) {
+ EventBus.getDefault().post(ExpandTreeNodeRequestEvent(node))
+ }
+
+ private fun requestCollapseNode(
+ node: TreeNode,
+ includeSubnodes: Boolean,
+ ) {
+ EventBus.getDefault().post(CollapseTreeNodeRequestEvent(node, includeSubnodes))
+ }
}
diff --git a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt
index 565d989c2f..8c3c29860e 100644
--- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt
+++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt
@@ -7,6 +7,7 @@ import com.itsaky.androidide.plugins.manager.core.PluginManager
import com.itsaky.androidide.plugins.manager.loaders.toPluginMetadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION
import java.io.File
/**
@@ -141,7 +142,8 @@ class PluginRepositoryImpl(
Log.w(TAG, "Error uninstalling existing plugin: ${e.message}")
}
- val fileExtension = if (pluginFile.name.endsWith(".cgp")) ".cgp" else ".apk"
+ val fileExtension =
+ if (pluginFile.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true)) ".$PLUGIN_ARCHIVE_EXTENSION" else ".apk"
val finalFileName = "${pluginId}$fileExtension"
if (!pluginsDir.exists()) {
diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt
new file mode 100644
index 0000000000..29cdccc75d
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepository.kt
@@ -0,0 +1,38 @@
+package com.itsaky.androidide.repositories
+
+import java.io.File
+
+/**
+ * Repository interface for template-collection (.cgt) operations.
+ */
+interface TemplateCollectionRepository {
+ data class CollectionInfo(
+ val templateNames: List,
+ )
+
+ /**
+ * Parse and validate a candidate .cgt archive without installing it.
+ */
+ suspend fun inspectCollection(candidateFile: File): Result
+
+ /**
+ * Returns the filename (without extension) of an already-installed template collection
+ * matching [baseName] case-insensitively, or `null` if there is no collision.
+ */
+ suspend fun findExistingCollision(baseName: String): String?
+
+ /**
+ * Install [candidateFile] into the templates directory under [targetBaseName], reloading
+ * the template provider afterwards.
+ */
+ suspend fun installCollection(
+ candidateFile: File,
+ targetBaseName: String,
+ overwrite: Boolean,
+ ): Result
+
+ /**
+ * Check if the templates system is available (i.e. IDE setup has completed).
+ */
+ fun isTemplatesFeatureAvailable(): Boolean
+}
diff --git a/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt
new file mode 100644
index 0000000000..c3900bbac7
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImpl.kt
@@ -0,0 +1,227 @@
+package com.itsaky.androidide.repositories
+
+import com.itsaky.androidide.templates.ITemplateProvider
+import com.itsaky.androidide.templates.TemplateRecipe
+import com.itsaky.androidide.templates.impl.TemplateWarning
+import com.itsaky.androidide.templates.impl.zip.ZipTemplateReader
+import com.itsaky.androidide.utils.Environment
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION
+import org.adfa.constants.TEMPLATE_CORE_ARCHIVE
+import org.slf4j.LoggerFactory
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Implementation of [TemplateCollectionRepository]. Templates are pure data (a zip archive
+ * copied into [Environment.TEMPLATES_DIR]) so, unlike plugins, installing one never requires an
+ * app restart - [ITemplateProvider.getInstance] just needs to be reloaded.
+ *
+ * All suspend functions here hop to [Dispatchers.IO] internally, so callers don't need to. On
+ * failure, [installCollection] always leaves its `candidateFile` argument untouched (see that
+ * function's kdoc) so the caller can retry with the same file.
+ */
+class TemplateCollectionRepositoryImpl : TemplateCollectionRepository {
+ private companion object {
+ private val log = LoggerFactory.getLogger(TemplateCollectionRepositoryImpl::class.java)
+
+ /** Base filename of the bundled default templates archive - reserved, never a user collection. */
+ private val RESERVED_BASE_NAME = File(TEMPLATE_CORE_ARCHIVE).nameWithoutExtension
+
+ /** Case-insensitive match by base filename - the only stable "collection identity" available. */
+ private fun findCollisionFile(
+ templatesDir: File,
+ baseName: String,
+ ): File? =
+ templatesDir
+ .listFiles { file -> file.extension.equals(TEMPLATE_ARCHIVE_EXTENSION, ignoreCase = true) }
+ ?.firstOrNull { it.nameWithoutExtension.equals(baseName, ignoreCase = true) }
+
+ /**
+ * Renames [src] to [dst], falling back to copy+delete - renameTo() is unreliable on-device
+ * even for a same-directory move (confirmed during this PR). [src] is gone on success
+ * either way; left untouched on failure.
+ */
+ private fun moveFile(
+ src: File,
+ dst: File,
+ ): Boolean =
+ src.renameTo(dst) ||
+ runCatching { src.copyTo(dst, overwrite = true) }.isSuccess.also { copied -> if (copied) src.delete() }
+
+ // Serializes installCollection() calls targeting the same case-insensitive base name -
+ // random staging/backup filenames already prevent two concurrent installs from colliding
+ // on an intermediate path, but without this, both could still pass the collision check
+ // before either writes destFile, so the later swap would silently clobber the earlier one.
+ private val installLocks = ConcurrentHashMap()
+
+ private fun installLock(baseName: String): Mutex = installLocks.computeIfAbsent(baseName.lowercase()) { Mutex() }
+ }
+
+ override suspend fun inspectCollection(candidateFile: File): Result =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ val warnings = mutableListOf()
+ val templates =
+ ZipTemplateReader.read(candidateFile, warnings) { _, _, _, _, _ ->
+ TemplateRecipe { null }
+ }
+
+ if (templates.isEmpty()) {
+ warnings.forEach { log.warn("Template read warning: resId={}, args={}", it.resId, it.args) }
+ throw IllegalArgumentException("No valid templates found in archive: ${candidateFile.name}")
+ }
+
+ TemplateCollectionRepository.CollectionInfo(
+ templateNames = templates.map { it.templateNameStr },
+ )
+ }.onFailure { exception ->
+ if (exception is CancellationException) throw exception
+ log.error("Failed to inspect template collection: {}", candidateFile.name, exception)
+ }
+ }
+
+ override suspend fun findExistingCollision(baseName: String): String? =
+ withContext(Dispatchers.IO) {
+ try {
+ Environment.TEMPLATES_DIR?.let { findCollisionFile(it, baseName) }?.nameWithoutExtension
+ } catch (e: CancellationException) {
+ throw e
+ } catch (exception: Exception) {
+ log.error("Failed to check for an existing template collection: {}", baseName, exception)
+ null
+ }
+ }
+
+ /**
+ * Installs [candidateFile] as `.cgt` in [Environment.TEMPLATES_DIR]. On any
+ * failure (including a validation error), [candidateFile] is left untouched so the caller can
+ * retry - it's only deleted once the install has fully succeeded.
+ */
+ override suspend fun installCollection(
+ candidateFile: File,
+ targetBaseName: String,
+ overwrite: Boolean,
+ ): Result =
+ withContext(Dispatchers.IO) {
+ installLock(targetBaseName).withLock {
+ runCatching {
+ if (targetBaseName.equals(RESERVED_BASE_NAME, ignoreCase = true)) {
+ throw IllegalStateException("\"$targetBaseName\" is a reserved name and cannot be used")
+ }
+
+ // targetBaseName ends up as a single path segment below; reject anything that
+ // could make it span multiple segments (or escape templatesDir entirely) before
+ // it ever reaches a File constructor.
+ if (targetBaseName.isBlank() ||
+ targetBaseName.contains('/') ||
+ targetBaseName.contains('\\') ||
+ targetBaseName == "." ||
+ targetBaseName == ".."
+ ) {
+ throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"")
+ }
+
+ val templatesDir =
+ Environment.TEMPLATES_DIR
+ ?: throw IllegalStateException("Templates system not available")
+
+ // Reuse the same case-insensitive lookup findExistingCollision() uses, so a
+ // case-variant match (e.g. installing "mytemplates" when "MyTemplates.cgt" is
+ // already there) is caught here too instead of silently creating a duplicate.
+ val existingMatch = findCollisionFile(templatesDir, targetBaseName)
+ if (existingMatch != null && !overwrite) {
+ throw IllegalStateException(
+ "A template collection named \"$targetBaseName\" already exists",
+ )
+ }
+
+ // Overwrite the existing case-variant file in place (preserving its casing)
+ // rather than create a second, case-differing duplicate.
+ val destFile = existingMatch ?: File(templatesDir, "$targetBaseName.$TEMPLATE_ARCHIVE_EXTENSION")
+
+ // Belt-and-braces against the character check above: confirm the resolved path
+ // still lands directly inside templatesDir once symlinks/".." are resolved.
+ if (destFile.canonicalFile.parentFile != templatesDir.canonicalFile) {
+ throw IllegalArgumentException("Invalid template collection name: \"$targetBaseName\"")
+ }
+
+ // Stage a copy of the incoming archive fully under templatesDir before touching
+ // destFile, so a failure while writing the new content never destroys the
+ // existing collection. candidateFile itself is deliberately left alone here (not
+ // moved/deleted) so that if anything below fails, the caller can retry the whole
+ // call with the same file - it's only deleted once the swap and the provider
+ // reload have both fully succeeded. The staging (and backup) filenames carry a
+ // random suffix so two concurrent installCollection() calls targeting the same
+ // destFile never race on the same intermediate path.
+ val stagingFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.tmp")
+ candidateFile.copyTo(stagingFile, overwrite = true)
+
+ // Back up (rather than delete) any existing destFile, so it can be put back if
+ // the swap below fails for any reason - the existing collection is only ever
+ // removed once the new one is confirmed successfully in its place.
+ val hadExisting = destFile.exists()
+ val backupFile = File(templatesDir, "${destFile.name}.${UUID.randomUUID()}.bak")
+ if (hadExisting && !moveFile(destFile, backupFile)) {
+ stagingFile.delete()
+ throw IllegalStateException("Failed to back up existing file before replacing: ${destFile.name}")
+ }
+
+ // Both files are now on the same volume (templatesDir), so this is a cheap,
+ // same-directory move - renameTo() failing here (as opposed to across the
+ // temp/templates boundary candidateFile itself would have to cross) would be
+ // unexpected, but moveFile() falls back to a copy anyway.
+ if (!moveFile(stagingFile, destFile)) {
+ if (hadExisting && !moveFile(backupFile, destFile)) {
+ // Nothing more we can do here - surface it loudly rather than silently
+ // leaving the user's original collection sitting under the backup's
+ // random filename, invisible to findExistingCollision().
+ log.error(
+ "Failed to restore backup after a failed swap for \"{}\" - original content may still be at: {}",
+ destFile.name,
+ backupFile.name,
+ )
+ }
+ stagingFile.delete()
+ throw IllegalStateException("Failed to replace existing file: ${destFile.name}")
+ }
+
+ if (hadExisting && backupFile.exists() && !backupFile.delete()) {
+ log.warn("Installed but failed to delete backup file: {}", backupFile.name)
+ }
+
+ // The file swap above is the operation's real postcondition - it already fully
+ // succeeded by this point. A reload failure here (e.g. templatesDir briefly
+ // unreadable) shouldn't turn that into a reported failure: doing so would leave
+ // destFile installed on disk while the caller believes nothing happened and
+ // retries, immediately hitting a spurious "already exists".
+ try {
+ ITemplateProvider.getInstance(reload = true)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ log.error(
+ "Template collection installed but the provider failed to reload: {}",
+ destFile.name,
+ e,
+ )
+ }
+
+ if (!candidateFile.delete()) {
+ log.warn("Installed but failed to delete source temp file: {}", candidateFile.name)
+ }
+ Unit
+ }.onFailure { exception ->
+ if (exception is CancellationException) throw exception
+ log.error("Failed to install template collection: {}", candidateFile.name, exception)
+ }
+ }
+ }
+
+ override fun isTemplatesFeatureAvailable(): Boolean = Environment.TEMPLATES_DIR != null
+}
diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt
index 5a219cfedd..4324eec039 100644
--- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt
+++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt
@@ -76,6 +76,8 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.newSingleThreadContext
import kotlinx.coroutines.withContext
+import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION
+import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
@@ -88,7 +90,7 @@ import kotlin.math.abs
private const val MIN_FONT_SIZE = EditorPreferences.FONT_SIZE_MIN
private const val DEFAULT_FONT_SIZE = EditorPreferences.FONT_SIZE_DEFAULT
private const val MAX_FONT_SIZE = EditorPreferences.FONT_SIZE_MAX
-private val ARCHIVE_EXTENSIONS = setOf("apk", "cgp", "zip")
+private val ARCHIVE_EXTENSIONS = setOf("apk", PLUGIN_ARCHIVE_EXTENSION, TEMPLATE_ARCHIVE_EXTENSION, "zip")
/**
* A view that handles opened code editor.
diff --git a/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt b/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt
new file mode 100644
index 0000000000..a6667bb866
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/ui/compose/TooltipInterop.kt
@@ -0,0 +1,33 @@
+package com.itsaky.androidide.ui.compose
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalView
+import androidx.compose.ui.res.stringResource
+import com.itsaky.androidide.R
+import com.itsaky.androidide.idetooltips.TooltipManager
+
+/**
+ * Wires the existing long-press help system (`idetooltips`) into a composable. Compose has no
+ * native tooltip entry point yet (the bridge is tracked as ADFA-4381) - this reuses
+ * [TooltipManager] via interop instead of a one-off popup, anchored to the Compose hierarchy's
+ * root [android.view.View] since a `content://`/dialog composable has no Android `View` of its
+ * own to anchor a popup on.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun Modifier.longPressTooltip(
+ tag: String,
+ onLongClickLabel: String = stringResource(R.string.cd_show_help),
+): Modifier {
+ val context = LocalContext.current
+ val anchorView = LocalView.current
+ return combinedClickable(
+ onClick = {},
+ onLongClickLabel = onLongClickLabel,
+ onLongClick = { TooltipManager.showIdeCategoryTooltip(context, anchorView, tag) },
+ )
+}
diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt
new file mode 100644
index 0000000000..b319b686a9
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/ui/models/ExternalFileInstallUiModels.kt
@@ -0,0 +1,52 @@
+package com.itsaky.androidide.ui.models
+
+import androidx.annotation.StringRes
+import com.itsaky.androidide.repositories.TemplateCollectionRepository
+import java.io.File
+
+sealed class ExternalFileInstallUiEvent {
+ data class ConfirmTemplateInstall(
+ val tempFile: File,
+ val targetBaseName: String,
+ val overwrite: Boolean,
+ ) : ExternalFileInstallUiEvent()
+
+ data class IgnoreTemplateInstall(
+ val tempFile: File,
+ ) : ExternalFileInstallUiEvent()
+}
+
+sealed class ExternalFileInstallUiEffect {
+ data class ForwardToPluginManager(
+ val filePath: String,
+ ) : ExternalFileInstallUiEffect()
+
+ data class ShowTemplateInstallConfirmation(
+ val info: TemplateCollectionRepository.CollectionInfo,
+ val tempFile: File,
+ val suggestedBaseName: String,
+ ) : ExternalFileInstallUiEffect()
+
+ data class ShowTemplateNameConflict(
+ val existingName: String,
+ val info: TemplateCollectionRepository.CollectionInfo,
+ val tempFile: File,
+ ) : ExternalFileInstallUiEffect()
+
+ data class ShowError(
+ @StringRes val messageResId: Int,
+ val formatArgs: List = emptyList(),
+ ) : ExternalFileInstallUiEffect()
+
+ data class ShowSuccess(
+ @StringRes val messageResId: Int,
+ val formatArgs: List = emptyList(),
+ ) : ExternalFileInstallUiEffect() {
+ constructor(
+ @StringRes messageResId: Int,
+ vararg formatArgs: Any,
+ ) : this(messageResId, formatArgs.toList())
+ }
+
+ object Finish : ExternalFileInstallUiEffect()
+}
diff --git a/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt
index 151d631f4c..07c4642fbb 100644
--- a/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt
+++ b/app/src/main/java/com/itsaky/androidide/ui/models/PluginManagerUiState.kt
@@ -4,51 +4,119 @@ import android.net.Uri
import androidx.annotation.StringRes
import com.itsaky.androidide.plugins.PluginInfo
import com.itsaky.androidide.plugins.PluginMetadata
+import java.io.File
data class PluginManagerUiState(
- val isLoading: Boolean = false,
- val plugins: List = emptyList(),
- val isPluginManagerAvailable: Boolean = false,
- val isInstalling: Boolean = false
+ val isLoading: Boolean = false,
+ val plugins: List = emptyList(),
+ val isPluginManagerAvailable: Boolean = false,
+ val isInstalling: Boolean = false,
) {
- val isEmpty: Boolean
- get() = plugins.isEmpty() && !isLoading
+ val isEmpty: Boolean
+ get() = plugins.isEmpty() && !isLoading
- val showEmptyState: Boolean
- get() = isEmpty && isPluginManagerAvailable
+ val showEmptyState: Boolean
+ get() = isEmpty && isPluginManagerAvailable
+}
+
+/**
+ * Where a plugin archive to install comes from - either a `content://` [Uri] the user picked via
+ * SAF (any provider, including third-party ones), or a plain [File] this process already owns
+ * (the forwarded-`.cgp` case from [com.itsaky.androidide.activities.ExternalFileInstallActivity],
+ * which needs no [android.content.ContentResolver] round-trip since it's already a private file).
+ */
+sealed class PluginInstallSource {
+ data class ContentUri(
+ val uri: Uri,
+ ) : PluginInstallSource()
+
+ data class LocalFile(
+ val file: File,
+ ) : PluginInstallSource()
}
sealed class PluginManagerUiEvent {
- object LoadPlugins : PluginManagerUiEvent()
- data class EnablePlugin(val pluginId: String) : PluginManagerUiEvent()
- data class DisablePlugin(val pluginId: String) : PluginManagerUiEvent()
- data class UninstallPlugin(val pluginId: String) : PluginManagerUiEvent()
- data class InstallPlugin(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent()
- data class ConfirmOverwrite(val uri: Uri, val deleteSourceAfterInstall: Boolean) : PluginManagerUiEvent()
- object OpenFilePicker : PluginManagerUiEvent()
- data class ShowPluginDetails(val plugin: PluginInfo) : PluginManagerUiEvent()
+ object LoadPlugins : PluginManagerUiEvent()
+
+ data class EnablePlugin(
+ val pluginId: String,
+ ) : PluginManagerUiEvent()
+
+ data class DisablePlugin(
+ val pluginId: String,
+ ) : PluginManagerUiEvent()
+
+ data class UninstallPlugin(
+ val pluginId: String,
+ ) : PluginManagerUiEvent()
+
+ data class InstallPlugin(
+ val source: PluginInstallSource,
+ val deleteSourceAfterInstall: Boolean,
+ ) : PluginManagerUiEvent()
+
+ data class ConfirmOverwrite(
+ val source: PluginInstallSource,
+ val deleteSourceAfterInstall: Boolean,
+ ) : PluginManagerUiEvent()
+
+ data class CancelPendingInstall(
+ val source: PluginInstallSource,
+ ) : PluginManagerUiEvent()
+
+ object OpenFilePicker : PluginManagerUiEvent()
+
+ data class ShowPluginDetails(
+ val plugin: PluginInfo,
+ ) : PluginManagerUiEvent()
}
sealed class PluginManagerUiEffect {
- data class ShowError(@StringRes val messageResId: Int, val formatArgs: List = emptyList()) : PluginManagerUiEffect()
- data class ShowSuccess(@StringRes val messageResId: Int) : PluginManagerUiEffect()
- data class ShowPluginDetails(val plugin: PluginInfo) : PluginManagerUiEffect()
- object OpenFilePicker : PluginManagerUiEffect()
- data class ShowUninstallConfirmation(val plugin: PluginInfo) : PluginManagerUiEffect()
- object ShowRestartPrompt : PluginManagerUiEffect()
- data class ShowOverwriteConfirmation(
- val existing: PluginInfo,
- val incomingMetadata: PluginMetadata,
- val uri: Uri,
- val deleteSourceAfterInstall: Boolean
- ) : PluginManagerUiEffect()
+ data class ShowError(
+ @StringRes val messageResId: Int,
+ val formatArgs: List = emptyList(),
+ ) : PluginManagerUiEffect()
+
+ data class ShowSuccess(
+ @StringRes val messageResId: Int,
+ ) : PluginManagerUiEffect()
+
+ data class ShowPluginDetails(
+ val plugin: PluginInfo,
+ ) : PluginManagerUiEffect()
+
+ object OpenFilePicker : PluginManagerUiEffect()
+
+ data class ShowUninstallConfirmation(
+ val plugin: PluginInfo,
+ ) : PluginManagerUiEffect()
+
+ object ShowRestartPrompt : PluginManagerUiEffect()
+
+ data class ShowOverwriteConfirmation(
+ val existing: PluginInfo,
+ val incomingMetadata: PluginMetadata,
+ val source: PluginInstallSource,
+ val deleteSourceAfterInstall: Boolean,
+ ) : PluginManagerUiEffect()
}
sealed class PluginOperation {
- object None : PluginOperation()
- object Loading : PluginOperation()
- object Installing : PluginOperation()
- data class Enabling(val pluginId: String) : PluginOperation()
- data class Disabling(val pluginId: String) : PluginOperation()
- data class Uninstalling(val pluginId: String) : PluginOperation()
-}
\ No newline at end of file
+ object None : PluginOperation()
+
+ object Loading : PluginOperation()
+
+ object Installing : PluginOperation()
+
+ data class Enabling(
+ val pluginId: String,
+ ) : PluginOperation()
+
+ data class Disabling(
+ val pluginId: String,
+ ) : PluginOperation()
+
+ data class Uninstalling(
+ val pluginId: String,
+ ) : PluginOperation()
+}
diff --git a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt
index 16a28891a9..3d1ca6a776 100644
--- a/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt
+++ b/app/src/main/java/com/itsaky/androidide/utils/ApkInstaller.kt
@@ -8,7 +8,6 @@ import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.os.Process
import androidx.core.app.PendingIntentCompat
-import androidx.core.content.FileProvider
import com.itsaky.androidide.actions.build.DebugAction
import com.itsaky.androidide.buildinfo.BuildInfo
import com.itsaky.androidide.services.InstallationResultReceiver
@@ -23,7 +22,6 @@ import java.io.File
* @author Akash Yadav
*/
object ApkInstaller {
-
private val log = LoggerFactory.getLogger(ApkInstaller::class.java)
private const val DEBUG_FALLBACK_INSTALLER = false
@@ -40,9 +38,10 @@ object ApkInstaller {
launchInDebugMode: Boolean = false,
debugFallbackInstaller: Boolean = DEBUG_FALLBACK_INSTALLER,
): Boolean {
- val isValidApk = withContext(Dispatchers.IO) {
- apk.exists() && apk.isFile && apk.extension == "apk"
- }
+ val isValidApk =
+ withContext(Dispatchers.IO) {
+ apk.exists() && apk.isFile && apk.extension.equals("apk", ignoreCase = true)
+ }
if (!isValidApk) {
log.error("File is not an APK: {}", apk)
return false
@@ -60,7 +59,7 @@ object ApkInstaller {
if (DeviceUtils.isMiui() || debugFallbackInstaller) {
log.warn(
"Cannot use session-based installer on this device." +
- " Falling back to intent-based installer."
+ " Falling back to intent-based installer.",
)
installUsingIntent(context, apk, baseIntent)
@@ -71,9 +70,12 @@ object ApkInstaller {
}
@Suppress("DEPRECATION", "RequestInstallPackagesPolicy")
- private fun installUsingIntent(context: Context, apk: File, intent: Intent) {
- val authority = "${context.packageName}.providers.fileprovider"
- val uri = FileProvider.getUriForFile(context, authority, apk)
+ private fun installUsingIntent(
+ context: Context,
+ apk: File,
+ intent: Intent,
+ ) {
+ val uri = context.fileProviderUriFor(apk)
intent.setAction(Intent.ACTION_INSTALL_PACKAGE)
intent.setDataAndType(uri, "application/vnd.android.package-archive")
intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
@@ -101,15 +103,18 @@ object ApkInstaller {
try {
session = installer.openSession(sessionId)
- val callback = requireNotNull(getCallbackIntent(context, intent, sessionId)) {
- "PackageInstaller callback intent is null"
- }
+ val callback =
+ requireNotNull(getCallbackIntent(context, intent, sessionId)) {
+ "PackageInstaller callback intent is null"
+ }
addToSession(session, apk)
session.commit(callback.intentSender)
} catch (t: Throwable) {
runCatching { installer.abandonSession(sessionId) }
throw t
- } finally { session?.close() }
+ } finally {
+ session?.close()
+ }
}
}.onFailure { error ->
log.error("Package installation failed", error)
@@ -143,14 +148,18 @@ object ApkInstaller {
}
}
- private fun getCallbackIntent(context: Context, intent: Intent, sessionId: Int): PendingIntent? {
- val intent = intent.apply {
- action = InstallationResultReceiver.ACTION_INSTALL_STATUS
- setClass(context, InstallationResultReceiver::class.java)
- setPackage(context.packageName)
- addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
- }
-
+ private fun getCallbackIntent(
+ context: Context,
+ intent: Intent,
+ sessionId: Int,
+ ): PendingIntent? {
+ val intent =
+ intent.apply {
+ action = InstallationResultReceiver.ACTION_INSTALL_STATUS
+ setClass(context, InstallationResultReceiver::class.java)
+ setPackage(context.packageName)
+ addFlags(Intent.FLAG_RECEIVER_FOREGROUND)
+ }
return PendingIntentCompat.getBroadcast(
context,
@@ -177,4 +186,4 @@ object ApkInstaller {
session.fsync(outStream)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt b/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt
new file mode 100644
index 0000000000..7c5b779269
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/utils/InstallTempFiles.kt
@@ -0,0 +1,60 @@
+package com.itsaky.androidide.utils
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.util.UUID
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicLong
+
+/**
+ * Shared `filesDir/temp` staging area for the .cgp/.cgt install flows (ExternalFileInstallViewModel,
+ * and PluginManagerViewModel's ContentUri branch) - centralizes temp-file naming so both ViewModels
+ * don't duplicate it, and sweeps orphans left behind by a hand-off that never completed (e.g.
+ * process death between ExternalFileInstallViewModel sending ForwardToPluginManager and
+ * PluginManagerActivity reading the pending-install-file extra).
+ */
+object InstallTempFiles {
+ private val MAX_AGE_MS = TimeUnit.HOURS.toMillis(1)
+
+ // Stale entries can only ever appear once an hour (MAX_AGE_MS), so there's no point
+ // re-scanning the directory on every single newTempFile() call - throttle to once per
+ // interval instead of doing a full listFiles()+lastModified() pass every time. An AtomicLong
+ // (rather than a plain var) since newTempFile() can be called concurrently from both
+ // ExternalFileInstallViewModel and PluginManagerViewModel's coroutines.
+ private val SWEEP_INTERVAL_MS = TimeUnit.MINUTES.toMillis(10)
+ private val lastSweepAtMs = AtomicLong(0L)
+
+ /**
+ * Creates a uniquely-named `_.` file under `filesDir/temp`. Suspends
+ * and dispatches to [Dispatchers.IO] internally - mkdirs() and the periodic directory
+ * sweep/delete below are real filesystem work, so callers don't need their own withContext to
+ * keep this off the caller's (possibly Main) dispatcher.
+ */
+ suspend fun newTempFile(
+ filesDir: File,
+ prefix: String,
+ extension: String,
+ ): File =
+ withContext(Dispatchers.IO) {
+ val tempDir = File(filesDir, "temp").apply { mkdirs() }
+ sweepStaleIfDue(tempDir)
+ File(tempDir, "${prefix}_${UUID.randomUUID()}.$extension")
+ }
+
+ private fun sweepStaleIfDue(tempDir: File) {
+ val now = System.currentTimeMillis()
+ val last = lastSweepAtMs.get()
+ if (now - last < SWEEP_INTERVAL_MS) return
+ // Loses the race to another concurrent caller -> that caller's sweep already covers this
+ // interval, so skip rather than sweep twice.
+ if (!lastSweepAtMs.compareAndSet(last, now)) return
+
+ val cutoff = now - MAX_AGE_MS
+ tempDir.listFiles()?.forEach { file ->
+ if (file.lastModified() < cutoff) {
+ file.delete()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt
index 3655a19784..0bcf662ba4 100644
--- a/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt
+++ b/app/src/main/java/com/itsaky/androidide/utils/IntentUtils.kt
@@ -22,7 +22,6 @@ import android.content.Intent
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.core.app.ShareCompat
-import androidx.core.content.FileProvider
import com.itsaky.androidide.R
import com.itsaky.androidide.utils.ImageUtils.ImageType.TYPE_UNKNOWN
import org.slf4j.LoggerFactory
@@ -88,12 +87,7 @@ object IntentUtils {
mimeType: String = MIME_ANY,
intentAction: String = Intent.ACTION_SEND,
) {
- val uri =
- FileProvider.getUriForFile(
- context,
- "${context.packageName}.providers.fileprovider",
- file,
- )
+ val uri = context.fileProviderUriFor(file)
val intent =
ShareCompat
.IntentBuilder(context)
diff --git a/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt b/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt
new file mode 100644
index 0000000000..6c894807a6
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/utils/LastValueGate.kt
@@ -0,0 +1,39 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.utils
+
+/**
+ * Tracks the last value handed to [consume], so a caller can tell "already handled" from "new"
+ * without an Activity `savedInstanceState` check. Meant to live as a field on a `ViewModel`: it
+ * survives a configuration change (same instance, so a repeat [consume] of the same value is a
+ * no-op), but resets after process death (a fresh instance is created), so a process-death
+ * recreation still processes a restored pending value instead of silently dropping it.
+ *
+ * Not thread-safe: [lastHandled] is unsynchronized, so call [consume] from a single thread only
+ * (e.g. always from the main thread, as every current call site does).
+ */
+class LastValueGate {
+ private var lastHandled: T? = null
+
+ /** Returns true the first time [value] is passed, or if it differs from the last one seen. */
+ fun consume(value: T): Boolean {
+ if (lastHandled == value) return false
+ lastHandled = value
+ return true
+ }
+}
diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt
index 790276a4de..5c220f86c8 100644
--- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt
+++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.future.await
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION
import org.slf4j.LoggerFactory
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
@@ -150,7 +151,7 @@ class BuildViewModel : ViewModel() {
val isDebug = variant.name.contains("debug", ignoreCase = true)
return pluginDir
- .listFiles { file -> file.extension.equals("cgp", ignoreCase = true) }
+ .listFiles { file -> file.extension.equals(PLUGIN_ARCHIVE_EXTENSION, ignoreCase = true) }
?.filter { it.name.contains("-debug") == isDebug }
?.maxByOrNull { it.lastModified() }
}
diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt
new file mode 100644
index 0000000000..f2bb0c6dc3
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModel.kt
@@ -0,0 +1,374 @@
+package com.itsaky.androidide.viewmodels
+
+import android.content.ContentResolver
+import android.net.Uri
+import androidx.annotation.StringRes
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.itsaky.androidide.repositories.PluginRepository
+import com.itsaky.androidide.repositories.TemplateCollectionRepository
+import com.itsaky.androidide.resources.R
+import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect
+import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent
+import com.itsaky.androidide.utils.InstallTempFiles
+import com.itsaky.androidide.utils.LastValueGate
+import com.itsaky.androidide.utils.UriFileImporter
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION
+import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION
+import org.slf4j.LoggerFactory
+import java.io.File
+
+/**
+ * Handles a `.cgp`/`.cgt` file opened from outside the app (e.g. an email attachment), backing
+ * [com.itsaky.androidide.activities.ExternalFileInstallActivity].
+ */
+class ExternalFileInstallViewModel(
+ private val pluginRepository: PluginRepository,
+ private val templateCollectionRepository: TemplateCollectionRepository,
+ private val contentResolver: ContentResolver,
+ private val filesDir: File,
+) : ViewModel() {
+ private companion object {
+ private val log = LoggerFactory.getLogger(ExternalFileInstallViewModel::class.java)
+ private val UNSAFE_FILENAME_CHARS = Regex("[\\\\/:*?\"<>|]")
+
+ // A cold OS-triggered launch of this activity can win the race against IDEApplication's
+ // async setup (device-unlock -> CredentialProtectedApplicationLoader.load(), which itself
+ // chains a long, unbounded sequence of Sentry/Firebase/EventBus/WorkManager/Termux/plugin
+ // init work), so isPluginManagerAvailable()/isTemplatesFeatureAvailable() are polled
+ // instead of failing on the very first check. ~8s total gives real cold starts a
+ // realistic margin; there's no true completion signal to await instead (see ADFA-4934
+ // code review notes), so this remains a bounded-poll approximation, not a hard guarantee.
+ private const val SETUP_WAIT_ATTEMPTS = 20
+ private const val SETUP_WAIT_INTERVAL_MS = 400L
+
+ // Bounds suggestUniqueBaseName()'s search - a pathological repository (or a huge run of
+ // pre-existing "foo (2)", "foo (3)", ... collections) must not hang the Rename dialog
+ // forever waiting for a free name.
+ private const val MAX_SUGGESTION_ATTEMPTS = 50
+ }
+
+ // Buffered (not rendezvous): onReceived() runs via Dispatchers.Main.immediate right after
+ // Activity.onCreate() starts collecting uiEffect, and a synchronous decision path (e.g. an
+ // unsupported file type) can otherwise complete before the collector actually attaches,
+ // silently dropping the effect.
+ private val _uiEffect = Channel(capacity = Channel.BUFFERED)
+ val uiEffect = _uiEffect.receiveAsFlow()
+
+ // onReceived() must run at most once per distinct uri per ViewModel instance: this instance
+ // survives a rotation (so a duplicate call there for the same uri is a no-op, not a
+ // re-processed intent), but is recreated fresh by Koin after process death (so the fresh
+ // instance still processes the restored intent instead of the call being skipped entirely).
+ private val receivedUriGate = LastValueGate()
+
+ private val _isInstalling = MutableStateFlow(false)
+ val isInstalling: StateFlow = _isInstalling.asStateFlow()
+
+ // Monotonically increasing per onReceived() call, assigned synchronously (before launching
+ // the coroutine below) so it always reflects real intent-arrival order. Two onReceived()
+ // calls in quick succession (ExternalFileInstallActivity is singleTask, so a second VIEW
+ // intent for a *different* file reaches this same instance via onNewIntent) run as
+ // independent coroutines with no guarantee the first *finishes* before the second - a slow
+ // first request can otherwise complete its async work (copy/inspect/collision-check) after a
+ // faster second request already committed, and overwrite the Compose screen's single
+ // dialogState slot with stale info. isCurrentGeneration() below lets each request notice, at
+ // its final commit point, that it's been superseded and should abandon silently instead.
+ private var currentRequestGeneration = 0
+
+ // Tracks the temp file behind the most recently *committed* .cgt confirm/conflict dialog -
+ // used to clean it up the moment a newer request supersedes it, rather than silently
+ // orphaning it for InstallTempFiles' hour-long sweep. Only ever touched by whichever request
+ // currently holds isCurrentGeneration()'s "true" (see supersedePendingConfirmation()), so
+ // there's no ordering ambiguity about which file it refers to.
+ private var pendingConfirmationTempFile: File? = null
+
+ // The generation pendingConfirmationTempFile actually belongs to - NOT necessarily
+ // currentRequestGeneration, which can already have moved on to a newer, still-in-flight
+ // request by the time the user taps a button on the dialog still on screen (its onReceived()
+ // bumped the counter synchronously, but hasn't reached supersedePendingConfirmation() yet).
+ // confirmTemplateInstall()/onEvent() must key off this, not the live counter, or a stale
+ // dialog's action gets misattributed to the newer request and can tear the Activity down out
+ // from under it.
+ private var pendingConfirmationGeneration: Int = 0
+
+ private fun isCurrentGeneration(generation: Int) = generation == currentRequestGeneration
+
+ private suspend fun supersedePendingConfirmation(
+ newPendingFile: File?,
+ newGeneration: Int,
+ ) {
+ pendingConfirmationTempFile?.let { old -> if (old != newPendingFile) deleteQuietly(old) }
+ pendingConfirmationTempFile = newPendingFile
+ pendingConfirmationGeneration = newGeneration
+ }
+
+ /** Call once, from `Activity.onCreate()`/`onNewIntent()`, with the VIEW intent's data [Uri]. */
+ fun onReceived(uri: Uri) {
+ if (!receivedUriGate.consume(uri)) return
+
+ val generation = ++currentRequestGeneration
+
+ viewModelScope.launch {
+ val displayName = withContext(Dispatchers.IO) { UriFileImporter.getDisplayName(contentResolver, uri) }
+ val extension = displayName?.substringAfterLast('.', "")?.lowercase()
+
+ if (displayName.isNullOrBlank() || extension.isNullOrBlank()) {
+ sendErrorAndFinish(generation, R.string.msg_invalid_incoming_file)
+ return@launch
+ }
+
+ if (extension != PLUGIN_ARCHIVE_EXTENSION && extension != TEMPLATE_ARCHIVE_EXTENSION) {
+ sendErrorAndFinish(generation, R.string.msg_unsupported_file_type)
+ return@launch
+ }
+
+ val featureAvailable =
+ if (extension == PLUGIN_ARCHIVE_EXTENSION) {
+ pluginRepository::isPluginManagerAvailable
+ } else {
+ templateCollectionRepository::isTemplatesFeatureAvailable
+ }
+ if (!awaitAvailable(featureAvailable)) {
+ sendErrorAndFinish(generation, R.string.msg_ide_setup_incomplete)
+ return@launch
+ }
+
+ val destination = InstallTempFiles.newTempFile(filesDir, "incoming", extension)
+
+ val tempFile =
+ try {
+ withContext(Dispatchers.IO) {
+ UriFileImporter.copyUriToFile(contentResolver, uri, destination) {
+ IllegalStateException("Cannot open file")
+ }
+ destination
+ }
+ } catch (e: CancellationException) {
+ withContext(NonCancellable + Dispatchers.IO) { deleteQuietlyBlocking(destination) }
+ throw e
+ } catch (e: Exception) {
+ log.error("Failed to copy incoming file", e)
+ withContext(Dispatchers.IO) { deleteQuietlyBlocking(destination) }
+ sendErrorAndFinish(generation, R.string.msg_invalid_incoming_file)
+ return@launch
+ }
+
+ if (!isCurrentGeneration(generation)) {
+ // A newer VIEW intent has since arrived and is now authoritative - abandon this
+ // one silently rather than emit an effect that would incorrectly supersede it.
+ deleteQuietly(tempFile)
+ return@launch
+ }
+
+ val baseName = sanitizeBaseName(displayName.substringBeforeLast('.', "templates"))
+
+ if (extension == PLUGIN_ARCHIVE_EXTENSION) {
+ // Forwarded as a plain path, not a content:// Uri: both activities run in this
+ // same process and already trust filesDir paths, so PluginManagerViewModel can
+ // install straight from this file instead of copying it a second time.
+ supersedePendingConfirmation(null, generation)
+ _uiEffect.trySend(ExternalFileInstallUiEffect.ForwardToPluginManager(tempFile.absolutePath))
+ } else {
+ dispatchTemplateInstall(tempFile, baseName, generation)
+ }
+ }
+ }
+
+ private suspend fun awaitAvailable(check: () -> Boolean): Boolean {
+ repeat(SETUP_WAIT_ATTEMPTS) { attempt ->
+ if (check()) return true
+ if (attempt < SETUP_WAIT_ATTEMPTS - 1) delay(SETUP_WAIT_INTERVAL_MS)
+ }
+ return false
+ }
+
+ private suspend fun dispatchTemplateInstall(
+ tempFile: File,
+ baseName: String,
+ generation: Int,
+ ) {
+ val info =
+ templateCollectionRepository.inspectCollection(tempFile).getOrElse { exception ->
+ log.warn("Invalid template collection file: {}", tempFile.name, exception)
+ deleteQuietly(tempFile)
+ sendErrorAndFinish(generation, R.string.msg_template_invalid_file)
+ return
+ }
+
+ val existing = templateCollectionRepository.findExistingCollision(baseName)
+
+ if (!isCurrentGeneration(generation)) {
+ deleteQuietly(tempFile)
+ return
+ }
+
+ supersedePendingConfirmation(tempFile, generation)
+ // This dialog's buttons must start enabled regardless of whether some earlier,
+ // now-abandoned generation's install is still finishing up in the background (see
+ // confirmTemplateInstall()'s own generation check for the other half of this).
+ _isInstalling.value = false
+ if (existing == null) {
+ _uiEffect.trySend(
+ ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation(info, tempFile, baseName),
+ )
+ } else {
+ _uiEffect.trySend(
+ ExternalFileInstallUiEffect.ShowTemplateNameConflict(existing, info, tempFile),
+ )
+ }
+ }
+
+ fun onEvent(event: ExternalFileInstallUiEvent) {
+ when (event) {
+ is ExternalFileInstallUiEvent.ConfirmTemplateInstall -> {
+ confirmTemplateInstall(event.tempFile, event.targetBaseName, event.overwrite)
+ }
+
+ is ExternalFileInstallUiEvent.IgnoreTemplateInstall -> {
+ // If this doesn't match, the dialog this event was fired from has already been
+ // superseded (and its tempFile already deleted by supersedePendingConfirmation) -
+ // nothing left on screen to Finish, and Finish-ing anyway would tear down the
+ // Activity out from under whatever newer dialog is now showing.
+ if (pendingConfirmationTempFile == event.tempFile) {
+ pendingConfirmationTempFile = null
+ viewModelScope.launch {
+ deleteQuietly(event.tempFile)
+ _uiEffect.trySend(ExternalFileInstallUiEffect.Finish)
+ }
+ }
+ }
+ }
+ }
+
+ private fun confirmTemplateInstall(
+ tempFile: File,
+ targetBaseName: String,
+ overwrite: Boolean,
+ ) {
+ // Guards against a double-tap on Install/Overwrite/Rename firing this twice concurrently -
+ // the second call's renameTo()/copyTo() would otherwise race the first's on the same
+ // tempFile and surface a spurious failure toast.
+ if (_isInstalling.value) return
+ // If this doesn't match, the dialog this event was fired from has already been superseded
+ // (its tempFile already deleted by supersedePendingConfirmation) - there's nothing left to
+ // install, and proceeding anyway would mean using currentRequestGeneration as this
+ // install's generation, misattributing it to whatever newer request bumped the counter.
+ if (pendingConfirmationTempFile != tempFile) return
+ _isInstalling.value = true
+ // The generation the now-showing dialog was committed under - NOT currentRequestGeneration,
+ // which may already have moved on to a newer, still-in-flight request (see
+ // pendingConfirmationGeneration's kdoc). This install must neither tear down the Activity
+ // out from under that newer request nor touch state that by then belongs to it.
+ val generation = pendingConfirmationGeneration
+ // From here on, tempFile's fate is owned by this install attempt, not "a dialog awaiting
+ // an answer" - a subsequent onReceived() for a different file must not delete it out from
+ // under an install already in flight.
+ pendingConfirmationTempFile = null
+
+ viewModelScope.launch {
+ templateCollectionRepository
+ .installCollection(tempFile, targetBaseName, overwrite)
+ .onSuccess {
+ // The install genuinely happened (the file's on disk in templatesDir) even if
+ // a newer request has since taken over the screen, so still surface the
+ // success - but only tear down the Activity (Finish) if nothing newer is now
+ // relying on it staying alive. targetBaseName is included in the message so
+ // the toast is unambiguous even when it overlays a newer, unrelated dialog.
+ _uiEffect.trySend(
+ ExternalFileInstallUiEffect.ShowSuccess(R.string.msg_template_installed, targetBaseName),
+ )
+ if (isCurrentGeneration(generation)) {
+ // The Screen suspends on ShowSuccess until the flashbar's entrance
+ // animation actually finishes (flashSuccessAwaitShown) before processing
+ // the next buffered effect, so Finish here doesn't need its own delay.
+ _uiEffect.trySend(ExternalFileInstallUiEffect.Finish)
+ }
+ }.onFailure { exception ->
+ log.error("Failed to install template collection", exception)
+ if (isCurrentGeneration(generation)) {
+ // Deliberately don't delete tempFile or Finish here: the dialog the user
+ // was just on (install-confirm / name-conflict / rename) stays open so
+ // they can retry - e.g. pick a different name after a collision, or
+ // Overwrite instead. If a newer request has since superseded this dialog,
+ // there's nothing left on-screen to retry against, so skip ShowError too.
+ // Restore pendingConfirmationTempFile/Generation (cleared above on entry):
+ // a retry tap or Cancel/back on this still-open dialog must match again, or
+ // confirmTemplateInstall()/IgnoreTemplateInstall's guards would treat every
+ // button on it as a permanent no-op from here on.
+ pendingConfirmationTempFile = tempFile
+ pendingConfirmationGeneration = generation
+ _uiEffect.trySend(
+ ExternalFileInstallUiEffect.ShowError(
+ R.string.msg_template_install_failed,
+ listOf(exception.message ?: exception.javaClass.simpleName),
+ ),
+ )
+ }
+ }
+ if (isCurrentGeneration(generation)) {
+ _isInstalling.value = false
+ }
+ }
+ }
+
+ /**
+ * Suggests a unique base name for the rename dialog by appending "(2)", "(3)", etc.
+ *
+ * Each candidate is checked via [TemplateCollectionRepository.findExistingCollision] - a
+ * fresh directory listing per call - rather than listing `templatesDir` once and checking
+ * membership in-memory. Left as-is deliberately: [MAX_SUGGESTION_ATTEMPTS] already bounds
+ * the worst case, a real templates directory is realistically small (a user's own installed
+ * collections), and avoiding the redundant scans would mean adding a batch-listing method to
+ * [TemplateCollectionRepository] purely for this one call site's benefit.
+ */
+ suspend fun suggestUniqueBaseName(baseName: String): String {
+ var candidate = baseName
+ var suffix = 2
+ // Collision must be checked before the attempt-count bound, not after: checking
+ // `suffix <= MAX` first would let the bound short-circuit the very last candidate's
+ // collision check, silently returning it unverified once the cap is hit.
+ while (templateCollectionRepository.findExistingCollision(candidate) != null && suffix <= MAX_SUGGESTION_ATTEMPTS) {
+ candidate = "$baseName ($suffix)"
+ suffix++
+ }
+ return candidate
+ }
+
+ fun sanitizeBaseName(rawName: String): String = rawName.replace(UNSAFE_FILENAME_CHARS, "_").trim().ifBlank { "templates" }
+
+ private suspend fun sendErrorAndFinish(
+ generation: Int,
+ @StringRes messageResId: Int,
+ ) {
+ if (!isCurrentGeneration(generation)) return
+ // A stale (already-superseded) request never reaches here (see the isCurrentGeneration
+ // check above), so whatever's still pending at this point genuinely belongs to an earlier,
+ // now-being-terminated request and must be cleaned up rather than left dangling.
+ supersedePendingConfirmation(null, generation)
+ // See confirmTemplateInstall()'s onSuccess: the Screen suspends on ShowError until the
+ // flashbar is actually shown before processing Finish, so no delay is needed here either.
+ _uiEffect.trySend(ExternalFileInstallUiEffect.ShowError(messageResId))
+ _uiEffect.trySend(ExternalFileInstallUiEffect.Finish)
+ }
+
+ private suspend fun deleteQuietly(file: File) {
+ withContext(Dispatchers.IO) { deleteQuietlyBlocking(file) }
+ }
+
+ private fun deleteQuietlyBlocking(file: File) {
+ if (file.exists()) {
+ file.delete()
+ }
+ }
+}
diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt
index 24043b5f46..17d9f5cebe 100644
--- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt
+++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt
@@ -9,13 +9,19 @@ import androidx.lifecycle.viewModelScope
import com.itsaky.androidide.plugins.PluginInfo
import com.itsaky.androidide.repositories.PluginRepository
import com.itsaky.androidide.resources.R
+import com.itsaky.androidide.ui.models.PluginInstallSource
import com.itsaky.androidide.ui.models.PluginManagerUiEffect
import com.itsaky.androidide.ui.models.PluginManagerUiEvent
import com.itsaky.androidide.ui.models.PluginManagerUiState
import com.itsaky.androidide.ui.models.PluginOperation
import com.itsaky.androidide.utils.EditorDecorationBridge
+import com.itsaky.androidide.utils.InstallTempFiles
+import com.itsaky.androidide.utils.LastValueGate
import com.itsaky.androidide.utils.UriFileImporter
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -24,6 +30,7 @@ import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
+import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION
import java.io.File
/**
@@ -31,367 +38,504 @@ 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"
+ }
+
+ // Tracks the last forwarded-install file path (from ExternalFileInstallActivity) this
+ // instance has already shown a dialog for. Survives rotation (same ViewModel instance, via
+ // the ViewModelStore) so the dialog isn't re-popped on every rotation, but resets on process
+ // death (a fresh instance is created), so a process-death-recreated PluginManagerActivity
+ // still shows the dialog instead of silently dropping the forwarded install.
+ private val pendingInstallGate = LastValueGate()
+
+ // Completed once the first loadPlugins() call (from init{}) has concluded, successfully or
+ // not. resolveInstallConflict() awaits this before consulting _uiState.value.plugins, so an
+ // install confirmed immediately after a cold start can't race the async plugin-list load and
+ // skip the same-ID signature check by seeing an still-empty list.
+ private val initialLoadCompleted = CompletableDeferred()
+
+ /** See [pendingInstallGate] for why this, rather than an Activity `savedInstanceState`
+ * check, is what correctly distinguishes "already shown after a rotation" from "never shown
+ * because the process died". */
+ fun markPendingInstallHandled(filePath: String): Boolean = pendingInstallGate.consume(filePath)
+
+ // 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. Buffered (not rendezvous): a synchronous decision path
+ // (e.g. handlePendingInstallExtra()'s effect right after onCreate()/onNewIntent()) can
+ // otherwise complete before the Activity's collector actually attaches, silently dropping the
+ // effect - see ExternalFileInstallViewModel's identical reasoning for its own uiEffect.
+ private val _uiEffect = Channel(capacity = Channel.BUFFERED)
+ val uiEffect = _uiEffect.receiveAsFlow()
+
+ // Current operation tracking
+ 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.source,
+ event.deleteSourceAfterInstall,
+ )
+ }
+
+ is PluginManagerUiEvent.ConfirmOverwrite -> {
+ installPlugin(
+ event.source,
+ event.deleteSourceAfterInstall,
+ checkConflict = false,
+ )
+ }
+
+ is PluginManagerUiEvent.CancelPendingInstall -> {
+ // Only a forwarded LocalFile (our own disposable temp copy) is cleaned up here -
+ // nothing was installed, so a user-picked ContentUri source is never touched on
+ // decline (deletion there only ever happens after a *successful* install,
+ // matching the "delete after install" checkbox's label - there's no flag to
+ // consult here since a decline never installs anything).
+ viewModelScope.launch { deleteIfLocalFile(event.source) }
+ }
+
+ is PluginManagerUiEvent.OpenFilePicker -> {
+ openFilePicker()
+ }
+
+ is PluginManagerUiEvent.ShowPluginDetails -> {
+ showPluginDetails(event.plugin)
+ }
+ }
+ }
+
+ /**
+ * Load all plugins
+ */
+ private fun loadPlugins() {
+ if (!pluginRepository.isPluginManagerAvailable()) {
+ _uiState.update { it.copy(isPluginManagerAvailable = false) }
+ initialLoadCompleted.complete(Unit)
+ 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
+ // A no-op if already completed by an earlier loadPlugins() call - only the first
+ // call's outcome matters for initialLoadCompleted's purpose.
+ initialLoadCompleted.complete(Unit)
+ }
+ }
+
+ /**
+ * 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(
+ source: PluginInstallSource,
+ deleteSourceAfterInstall: Boolean,
+ checkConflict: Boolean = true,
+ ) {
+ viewModelScope.launch {
+ _currentOperation.value = PluginOperation.Installing
+ _uiState.update { it.copy(isInstalling = true) }
+
+ // ownedTempFile (the ContentUri case's own temp copy) is what the `finally` block
+ // below cleans up unconditionally. Note pluginRepository.installPluginFromFile()
+ // itself unconditionally deletes whatever `pluginFile` it's given once that's copied
+ // into the plugins directory - that's pre-existing behavior this function doesn't
+ // control (it also affects InstallFileAction.kt's direct callers). What
+ // deleteSourceAfterInstall/deleteInstallSource governs below is the *original*
+ // source's lifecycle instead: a user-picked ContentUri is only ever deleted after a
+ // successful install (see the onSuccess/onFailure split below), while a forwarded
+ // LocalFile temp copy is always cleaned up regardless of outcome.
+ var ownedTempFile: File? = null
+ var pluginFile: File? = null
+
+ try {
+ if (checkConflict) {
+ // See initialLoadCompleted's kdoc: guarantees _uiState.value.plugins reflects
+ // the real installed set before resolveInstallConflict() checks it below.
+ initialLoadCompleted.await()
+ }
+
+ pluginFile =
+ when (source) {
+ is PluginInstallSource.LocalFile -> {
+ source.file
+ }
+
+ is PluginInstallSource.ContentUri -> {
+ withContext(Dispatchers.IO) {
+ val fileName = UriFileImporter.getDisplayName(contentResolver, source.uri)
+ val extension =
+ if (fileName?.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true) == true) {
+ PLUGIN_ARCHIVE_EXTENSION
+ } else {
+ "apk"
+ }
+ val tempFile = InstallTempFiles.newTempFile(filesDir, "temp_plugin", extension)
+ // Assigned immediately (a plain, non-suspending write), before the
+ // suspending copy below - so a cancellation landing mid-copy still
+ // leaves ownedTempFile pointing at the file for `finally` to clean
+ // up. Assigning only after this whole block returns (e.g. via
+ // `.also{}` on the block's result) would miss that window: a
+ // cancellation right as the block finishes makes withContext throw
+ // instead of returning, so the assignment would never run.
+ ownedTempFile = tempFile
+
+ UriFileImporter.copyUriToFile(contentResolver, source.uri, tempFile) {
+ Exception("Cannot open file")
+ }
+ tempFile
+ }
+ }
+ }
+
+ if (checkConflict && resolveInstallConflict(pluginFile, source, deleteSourceAfterInstall)) {
+ return@launch
+ }
+
+ pluginRepository
+ .installPluginFromFile(pluginFile)
+ .onSuccess {
+ Log.d(TAG, "Plugin installed successfully")
+ _uiEffect.trySend(PluginManagerUiEffect.ShowSuccess(R.string.msg_plugin_installed))
+ loadPlugins()
+ _uiEffect.trySend(PluginManagerUiEffect.ShowRestartPrompt)
+
+ if (deleteSourceAfterInstall) {
+ deleteInstallSource(source)
+ }
+ }.onFailure { exception ->
+ Log.e(TAG, "Failed to install plugin", exception)
+ _uiEffect.trySend(
+ PluginManagerUiEffect.ShowError(
+ R.string.msg_plugin_install_failed,
+ listOf(exception.message ?: ""),
+ ),
+ )
+ // A failed install deletes nothing but our own disposable temp copy - a
+ // user-picked ContentUri is preserved so they can retry, matching
+ // deleteSourceAfterInstall's "delete after install [succeeds]" meaning.
+ deleteIfLocalFile(source)
+ }
+ } catch (e: CancellationException) {
+ // Matches the "always cleaned up regardless of outcome" comment above: cancellation
+ // is itself an outcome the forwarded temp file must not survive.
+ withContext(NonCancellable) { deleteIfLocalFile(source) }
+ throw e
+ } 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 ?: ""),
+ ),
+ )
+ deleteIfLocalFile(source)
+ } finally {
+ ownedTempFile?.let { file ->
+ withContext(NonCancellable + Dispatchers.IO) {
+ if (file.exists()) {
+ file.delete()
+ }
+ }
+ }
+ _uiState.update { it.copy(isInstalling = false) }
+ _currentOperation.value = PluginOperation.None
+ }
+ }
+ }
+
+ private suspend fun resolveInstallConflict(
+ pluginFile: File,
+ source: PluginInstallSource,
+ deleteSourceAfterInstall: Boolean,
+ ): Boolean {
+ val incoming = pluginRepository.getPluginMetadataFromFile(pluginFile).getOrNull()
+ if (incoming == null) {
+ Log.w(TAG, "Failed to read plugin metadata from ${pluginFile.name}; aborting install")
+ _uiEffect.trySend(PluginManagerUiEffect.ShowError(R.string.msg_plugin_invalid_file))
+ deleteIfLocalFile(source)
+ return true
+ }
+
+ val existing =
+ _uiState.value.plugins.find { it.metadata.id == incoming.id }
+ ?: return false
+
+ val signaturesMatch =
+ pluginRepository
+ .haveMatchingSignatures(pluginFile, existing.metadata.id)
+ .getOrDefault(false)
+
+ if (!signaturesMatch) {
+ _uiEffect.trySend(
+ PluginManagerUiEffect.ShowError(
+ R.string.msg_plugin_signature_mismatch,
+ listOf(existing.metadata.name),
+ ),
+ )
+ deleteIfLocalFile(source)
+ return true
+ }
+
+ // Deliberately don't delete the source yet: the user still needs to choose Replace or
+ // Cancel. ConfirmOverwrite re-runs installPlugin() to consume it on Replace;
+ // CancelPendingInstall cleans it up if they back out instead.
+ _uiEffect.trySend(
+ PluginManagerUiEffect.ShowOverwriteConfirmation(
+ existing = existing,
+ incomingMetadata = incoming,
+ source = source,
+ deleteSourceAfterInstall = deleteSourceAfterInstall,
+ ),
+ )
+ return true
+ }
+
+ /** A user-picked ContentUri is only ever deleted after a successful install (matching the
+ * "delete after install" checkbox's label) - a forwarded LocalFile temp copy is disposable
+ * regardless of outcome, so it's the only source type any non-success path cleans up here. */
+ private suspend fun deleteIfLocalFile(source: PluginInstallSource) {
+ if (source is PluginInstallSource.LocalFile) {
+ deleteInstallSource(source)
+ }
+ }
+
+ private suspend fun deleteInstallSource(source: PluginInstallSource) {
+ when (source) {
+ is PluginInstallSource.LocalFile -> {
+ withContext(Dispatchers.IO) {
+ if (source.file.exists() && !source.file.delete()) {
+ Log.w(TAG, "Failed to delete forwarded install file: ${source.file.absolutePath}")
+ }
+ }
+ }
+
+ is PluginInstallSource.ContentUri -> {
+ deleteSourceDocument(source.uri)
+ }
+ }
+ }
+
+ private suspend fun deleteSourceDocument(uri: Uri) {
+ withContext(Dispatchers.IO) {
+ try {
+ if (!DocumentsContract.deleteDocument(contentResolver, uri)) {
+ _uiEffect.trySend(
+ PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed),
+ )
+ }
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to delete source document", e)
+ _uiEffect.trySend(
+ PluginManagerUiEffect.ShowError(R.string.msg_source_delete_failed),
+ )
+ }
+ }
+ }
+
+ /**
+ * Open file picker
+ */
+ private fun openFilePicker() {
+ viewModelScope.launch {
+ _uiEffect.trySend(PluginManagerUiEffect.OpenFilePicker)
+ }
+ }
+
+ /**
+ * Show plugin details
+ */
+ private fun showPluginDetails(plugin: PluginInfo) {
+ viewModelScope.launch {
+ _uiEffect.trySend(PluginManagerUiEffect.ShowPluginDetails(plugin))
+ }
+ }
+
+ /**
+ * Check if a specific plugin operation is in progress
+ */
+ fun isPluginOperationInProgress(pluginId: String): Boolean =
+ when (val operation = _currentOperation.value) {
+ is PluginOperation.Enabling -> operation.pluginId == pluginId
+ is PluginOperation.Disabling -> operation.pluginId == pluginId
+ is PluginOperation.Uninstalling -> operation.pluginId == pluginId
+ else -> false
+ }
}
diff --git a/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt
new file mode 100644
index 0000000000..555511efb1
--- /dev/null
+++ b/app/src/test/java/com/itsaky/androidide/repositories/TemplateCollectionRepositoryImplTest.kt
@@ -0,0 +1,302 @@
+package com.itsaky.androidide.repositories
+
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.plugins.templates.CgtTemplateBuilder
+import com.itsaky.androidide.utils.Environment
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@OptIn(ExperimentalCoroutinesApi::class)
+class TemplateCollectionRepositoryImplTest {
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private lateinit var repository: TemplateCollectionRepository
+ private lateinit var templatesDir: File
+ private val previousTemplatesDir: File? = Environment.TEMPLATES_DIR
+
+ @Before
+ fun setup() {
+ repository = TemplateCollectionRepositoryImpl()
+ templatesDir = tempFolder.newFolder("templates")
+ Environment.TEMPLATES_DIR = templatesDir
+ }
+
+ @After
+ fun tearDown() {
+ Environment.TEMPLATES_DIR = previousTemplatesDir
+ }
+
+ private fun buildCgt(
+ name: String,
+ outputDir: File = tempFolder.newFolder(),
+ ): File =
+ CgtTemplateBuilder(name)
+ .description("A test template")
+ // ZipTemplateReader.read() fully builds a ProjectTemplate (not just metadata) and,
+ // absent this, falls back to Environment.PROJECTS_DIR - null outside a real app setup.
+ .defaultSaveLocation(tempFolder.newFolder().absolutePath)
+ .build(outputDir)
+
+ @Test
+ fun `isTemplatesFeatureAvailable is true when TEMPLATES_DIR is set`() {
+ assertThat(repository.isTemplatesFeatureAvailable()).isTrue()
+ }
+
+ @Test
+ fun `isTemplatesFeatureAvailable is false when TEMPLATES_DIR is null`() {
+ Environment.TEMPLATES_DIR = null
+ assertThat(repository.isTemplatesFeatureAvailable()).isFalse()
+ }
+
+ @Test
+ fun `inspectCollection returns template names for a valid archive`() =
+ runTest {
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.inspectCollection(cgt)
+
+ assertThat(result.isSuccess).isTrue()
+ assertThat(result.getOrNull()?.templateNames).containsExactly("Empty Activity")
+ }
+
+ @Test
+ fun `inspectCollection fails for a corrupted archive`() =
+ runTest {
+ val corrupted = File(tempFolder.newFolder(), "broken.cgt")
+ corrupted.writeText("not a zip file")
+
+ val result = repository.inspectCollection(corrupted)
+
+ assertThat(result.isFailure).isTrue()
+ }
+
+ @Test
+ fun `findExistingCollision matches an installed collection case-insensitively`() =
+ runTest {
+ File(templatesDir, "MyTemplates.cgt").writeText("placeholder")
+
+ val match = repository.findExistingCollision("mytemplates")
+
+ assertThat(match).isEqualTo("MyTemplates")
+ }
+
+ @Test
+ fun `findExistingCollision matches an uppercase CGT extension`() =
+ runTest {
+ File(templatesDir, "MyTemplates.CGT").writeText("placeholder")
+
+ val match = repository.findExistingCollision("mytemplates")
+
+ assertThat(match).isEqualTo("MyTemplates")
+ }
+
+ @Test
+ fun `findExistingCollision returns null when there is no match`() =
+ runTest {
+ val match = repository.findExistingCollision("does-not-exist")
+
+ assertThat(match).isNull()
+ }
+
+ @Test
+ fun `installCollection copies the archive into TEMPLATES_DIR and deletes the source`() =
+ runTest {
+ val cgt = buildCgt("Empty Activity")
+ val expectedBytes = cgt.readBytes()
+
+ val result = repository.installCollection(cgt, "my-templates", overwrite = false)
+
+ assertThat(result.isSuccess).isTrue()
+ val installed = File(templatesDir, "my-templates.cgt")
+ assertThat(installed.exists()).isTrue()
+ assertThat(installed.readBytes()).isEqualTo(expectedBytes)
+ assertThat(cgt.exists()).isFalse()
+ }
+
+ @Test
+ fun `installCollection without overwrite fails when the destination already exists`() =
+ runTest {
+ File(templatesDir, "my-templates.cgt").writeText("existing")
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, "my-templates", overwrite = false)
+
+ assertThat(result.isFailure).isTrue()
+ }
+
+ @Test
+ fun `installCollection without overwrite fails against an existing case-variant destination`() =
+ runTest {
+ File(templatesDir, "MyTemplates.CGT").writeText("existing")
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, "mytemplates", overwrite = false)
+
+ assertThat(result.isFailure).isTrue()
+ }
+
+ @Test
+ fun `installCollection with overwrite replaces an existing case-variant destination in place`() =
+ runTest {
+ val destination = File(templatesDir, "MyTemplates.CGT")
+ destination.writeText("stale content")
+ val cgt = buildCgt("Empty Activity")
+ val expectedBytes = cgt.readBytes()
+
+ val result = repository.installCollection(cgt, "mytemplates", overwrite = true)
+
+ assertThat(result.isSuccess).isTrue()
+ assertThat(destination.readBytes()).isEqualTo(expectedBytes)
+ }
+
+ @Test
+ fun `installCollection with overwrite replaces the existing destination`() =
+ runTest {
+ val destination = File(templatesDir, "my-templates.cgt")
+ destination.writeText("stale content")
+ val cgt = buildCgt("Empty Activity")
+ val expectedBytes = cgt.readBytes()
+
+ val result = repository.installCollection(cgt, "my-templates", overwrite = true)
+
+ assertThat(result.isSuccess).isTrue()
+ assertThat(destination.readBytes()).isEqualTo(expectedBytes)
+ }
+
+ @Test
+ fun `installCollection refuses to replace the reserved bundled core archive, even with overwrite`() =
+ runTest {
+ val bundledCore = File(templatesDir, "core.cgt")
+ bundledCore.writeText("bundled default templates")
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, "core", overwrite = true)
+
+ assertThat(result.isFailure).isTrue()
+ assertThat(bundledCore.readText()).isEqualTo("bundled default templates")
+ }
+
+ @Test
+ fun `installCollection refuses a reserved name case-insensitively`() =
+ runTest {
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, "CORE", overwrite = true)
+
+ assertThat(result.isFailure).isTrue()
+ }
+
+ @Test
+ fun `installCollection rejects a targetBaseName containing a path separator`() =
+ runTest {
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, "../evil", overwrite = false)
+
+ assertThat(result.isFailure).isTrue()
+ assertThat(File(templatesDir.parentFile, "evil.cgt").exists()).isFalse()
+ }
+
+ @Test
+ fun `installCollection rejects a targetBaseName that is a bare traversal segment`() =
+ runTest {
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, "..", overwrite = false)
+
+ assertThat(result.isFailure).isTrue()
+ }
+
+ @Test
+ fun `installCollection rejects a targetBaseName containing a backslash`() =
+ runTest {
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, "evil\\name", overwrite = false)
+
+ assertThat(result.isFailure).isTrue()
+ }
+
+ @Test
+ fun `installCollection rejects a bare dot targetBaseName`() =
+ runTest {
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, ".", overwrite = false)
+
+ assertThat(result.isFailure).isTrue()
+ }
+
+ @Test
+ fun `installCollection rejects a blank targetBaseName`() =
+ runTest {
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, " ", overwrite = false)
+
+ assertThat(result.isFailure).isTrue()
+ }
+
+ @Test
+ fun `installCollection preserves the existing collection if the incoming archive cannot be staged`() =
+ runTest {
+ val destination = File(templatesDir, "my-templates.cgt")
+ destination.writeText("stale but valid content")
+ // A candidate that no longer exists can't be copied into staging, so the staging
+ // step fails before destFile is ever touched.
+ val missingCandidate = File(tempFolder.newFolder(), "gone.cgt")
+
+ val result = repository.installCollection(missingCandidate, "my-templates", overwrite = true)
+
+ assertThat(result.isFailure).isTrue()
+ assertThat(destination.exists()).isTrue()
+ assertThat(destination.readText()).isEqualTo("stale but valid content")
+ }
+
+ @Test
+ fun `installCollection leaves candidateFile untouched so the caller can retry after a failure`() =
+ runTest {
+ // A reserved-name failure happens before any file I-O, so candidateFile must still be
+ // exactly where the caller left it - this is the contract ExternalFileInstallViewModel
+ // relies on to keep the retry dialog usable after a failed install.
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, "core", overwrite = true)
+
+ assertThat(result.isFailure).isTrue()
+ assertThat(cgt.exists()).isTrue()
+ }
+
+ @Test
+ fun `installCollection leaves no stray staging or backup files behind on a fresh install`() =
+ runTest {
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, "my-templates", overwrite = false)
+
+ assertThat(result.isSuccess).isTrue()
+ assertThat(templatesDir.listFiles()?.map { it.name }).containsExactly("my-templates.cgt")
+ }
+
+ @Test
+ fun `installCollection leaves no stray staging or backup files behind on an overwrite`() =
+ runTest {
+ File(templatesDir, "my-templates.cgt").writeText("stale content")
+ val cgt = buildCgt("Empty Activity")
+
+ val result = repository.installCollection(cgt, "my-templates", overwrite = true)
+
+ assertThat(result.isSuccess).isTrue()
+ assertThat(templatesDir.listFiles()?.map { it.name }).containsExactly("my-templates.cgt")
+ }
+}
diff --git a/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt
new file mode 100644
index 0000000000..fbf2784b15
--- /dev/null
+++ b/app/src/test/java/com/itsaky/androidide/viewmodels/ExternalFileInstallViewModelTest.kt
@@ -0,0 +1,406 @@
+package com.itsaky.androidide.viewmodels
+
+import android.content.Context
+import android.net.Uri
+import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.repositories.PluginRepository
+import com.itsaky.androidide.repositories.TemplateCollectionRepository
+import com.itsaky.androidide.ui.models.ExternalFileInstallUiEffect
+import com.itsaky.androidide.ui.models.ExternalFileInstallUiEvent
+import com.itsaky.androidide.viewmodel.MainDispatcherRule
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@OptIn(ExperimentalCoroutinesApi::class)
+class ExternalFileInstallViewModelTest {
+ @get:Rule
+ val instantExecutorRule = InstantTaskExecutorRule()
+
+ @get:Rule
+ val mainDispatcherRule = MainDispatcherRule()
+
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val pluginRepository = mockk(relaxed = true)
+ private val templateCollectionRepository = mockk(relaxed = true)
+
+ private lateinit var viewModel: ExternalFileInstallViewModel
+
+ @Before
+ fun setup() {
+ viewModel =
+ ExternalFileInstallViewModel(
+ pluginRepository = pluginRepository,
+ templateCollectionRepository = templateCollectionRepository,
+ contentResolver = context.contentResolver,
+ filesDir = tempFolder.root,
+ )
+ }
+
+ private fun sourceUriFor(
+ fileName: String,
+ content: String = "dummy",
+ ): Uri {
+ val file = File(tempFolder.newFolder(), fileName)
+ file.writeText(content)
+ return Uri.fromFile(file)
+ }
+
+ @Test
+ fun `unsupported extension shows error and finishes`() =
+ runTest {
+ viewModel.onReceived(sourceUriFor("notes.txt"))
+
+ val first = viewModel.uiEffect.first()
+ assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java)
+ }
+
+ @Test
+ fun `cgp when plugin manager unavailable shows setup-incomplete error`() =
+ runTest {
+ stubPluginManagerAvailable(false)
+
+ viewModel.onReceived(sourceUriFor("my-plugin.cgp"))
+
+ val first = viewModel.uiEffect.first()
+ assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java)
+ }
+
+ @Test
+ fun `cgt when templates unavailable shows setup-incomplete error`() =
+ runTest {
+ stubTemplatesFeatureAvailable(false)
+
+ viewModel.onReceived(sourceUriFor("my-templates.cgt"))
+
+ val first = viewModel.uiEffect.first()
+ assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java)
+ }
+
+ @Test
+ fun `fresh cgp forwards to plugin manager`() =
+ runTest {
+ stubPluginManagerAvailable(true)
+
+ viewModel.onReceived(sourceUriFor("my-plugin.cgp"))
+
+ val first = viewModel.uiEffect.first()
+ assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java)
+ }
+
+ @Test
+ fun `fresh cgt with no name collision shows install confirmation`() =
+ runTest {
+ stubTemplatesFeatureAvailable(true)
+ val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity"))
+ coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info)
+ coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null
+
+ viewModel.onReceived(sourceUriFor("my-templates.cgt"))
+
+ val first = viewModel.uiEffect.first()
+ assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation::class.java)
+ val effect = first as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
+ assertThat(effect.suggestedBaseName).isEqualTo("my-templates")
+ assertThat(effect.info.templateNames).containsExactly("Empty Activity")
+ }
+
+ @Test
+ fun `cgt with existing name collision shows name conflict`() =
+ runTest {
+ stubTemplatesFeatureAvailable(true)
+ val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity"))
+ coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info)
+ coEvery { templateCollectionRepository.findExistingCollision(any()) } returns "my-templates"
+
+ viewModel.onReceived(sourceUriFor("my-templates.cgt"))
+
+ val first = viewModel.uiEffect.first()
+ assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateNameConflict::class.java)
+ assertThat((first as ExternalFileInstallUiEffect.ShowTemplateNameConflict).existingName).isEqualTo("my-templates")
+ }
+
+ @Test
+ fun `a second onReceived for a different file cleans up the first file's still-pending temp copy`() =
+ runTest {
+ // Simulates a second VIEW intent for a different file arriving via onNewIntent() on
+ // the singleTask ExternalFileInstallActivity while the first file's confirmation
+ // dialog is still unanswered.
+ stubTemplatesFeatureAvailable(true)
+ val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity"))
+ coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info)
+ coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null
+
+ viewModel.onReceived(sourceUriFor("first.cgt"))
+ val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
+ val firstTempFile = firstEffect.tempFile
+ assertThat(firstTempFile.exists()).isTrue()
+
+ viewModel.onReceived(sourceUriFor("second.cgt"))
+ val secondEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
+
+ assertThat(firstTempFile.exists()).isFalse()
+ assertThat(secondEffect.tempFile).isNotEqualTo(firstTempFile)
+ assertThat(secondEffect.tempFile.exists()).isTrue()
+ }
+
+ @Test
+ fun `isInstalling for a superseded generation does not block a newer dialog's buttons`() =
+ runTest {
+ stubTemplatesFeatureAvailable(true)
+ val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity"))
+ coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info)
+ coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null
+
+ val installDeferred = CompletableDeferred>()
+ coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } coAnswers { installDeferred.await() }
+
+ viewModel.onReceived(sourceUriFor("first.cgt"))
+ val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
+ viewModel.onEvent(
+ ExternalFileInstallUiEvent.ConfirmTemplateInstall(firstEffect.tempFile, firstEffect.suggestedBaseName, overwrite = false),
+ )
+ assertThat(viewModel.isInstalling.value).isTrue()
+
+ // A second, unrelated file arrives (e.g. via onNewIntent on the singleTask activity)
+ // while the first file's install is still in flight.
+ viewModel.onReceived(sourceUriFor("second.cgt"))
+ viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
+
+ // The new dialog must not render with its buttons disabled just because an unrelated,
+ // already-superseded install is still finishing up in the background.
+ assertThat(viewModel.isInstalling.value).isFalse()
+
+ installDeferred.complete(Result.success(Unit))
+ viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowSuccess
+
+ // The now-completed, superseded install must not re-enable (or otherwise touch)
+ // isInstalling on behalf of the current, unrelated generation.
+ assertThat(viewModel.isInstalling.value).isFalse()
+ }
+
+ @Test
+ fun `confirming a stale dialog uses its own generation, not a newer request's`() =
+ runTest {
+ // Regression test: confirmTemplateInstall() must key off the generation the on-screen
+ // dialog was actually committed under (pendingConfirmationGeneration), not the live
+ // currentRequestGeneration counter, which a second onReceived() can already have bumped
+ // before its own dialog is shown.
+ stubTemplatesFeatureAvailable(true)
+ val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity"))
+ coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info)
+ coEvery { templateCollectionRepository.findExistingCollision("first") } returns null
+ coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returns Result.success(Unit)
+
+ val secondGate = CompletableDeferred()
+ coEvery { templateCollectionRepository.findExistingCollision("second") } coAnswers {
+ secondGate.await()
+ null
+ }
+
+ viewModel.onReceived(sourceUriFor("first.cgt"))
+ val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
+ val firstTempFile = firstEffect.tempFile
+
+ // A second VIEW intent arrives (e.g. via onNewIntent) while file A's dialog is still
+ // the one on screen - this bumps currentRequestGeneration synchronously, well before
+ // file B's own async pipeline (gated on secondGate) can commit its own dialog.
+ viewModel.onReceived(sourceUriFor("second.cgt"))
+
+ // The user taps Install on the still-visible (but now globally-stale) dialog for A.
+ viewModel.onEvent(
+ ExternalFileInstallUiEvent.ConfirmTemplateInstall(firstTempFile, firstEffect.suggestedBaseName, overwrite = false),
+ )
+
+ // File A's install genuinely succeeds - but must not Finish the Activity, since file
+ // B's request (a newer generation) is still in flight and hasn't shown its own dialog.
+ assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowSuccess::class.java)
+
+ secondGate.complete(Unit)
+ assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation::class.java)
+ }
+
+ @Test
+ fun `ignoring a stale dialog does not finish the activity out from under a newer one`() =
+ runTest {
+ stubTemplatesFeatureAvailable(true)
+ val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity"))
+ coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info)
+ coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null
+ coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returns Result.success(Unit)
+
+ viewModel.onReceived(sourceUriFor("first.cgt"))
+ val firstEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
+ val firstTempFile = firstEffect.tempFile
+
+ viewModel.onReceived(sourceUriFor("second.cgt"))
+ val secondEffect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
+
+ // A stale Ignore/Cancel tap for file A's now-replaced dialog must be a no-op - in
+ // particular it must not Finish the Activity out from under file B's current dialog.
+ viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(firstTempFile))
+
+ viewModel.onEvent(
+ ExternalFileInstallUiEvent.ConfirmTemplateInstall(
+ secondEffect.tempFile,
+ secondEffect.suggestedBaseName,
+ overwrite = false,
+ ),
+ )
+ assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowSuccess::class.java)
+ }
+
+ @Test
+ fun `retrying Install after a failed install actually attempts install again`() =
+ runTest {
+ // Regression test: confirmTemplateInstall() clears pendingConfirmationTempFile on
+ // entry (transferring tempFile's "ownership" to the install attempt) but the dialog is
+ // deliberately left open on failure so the user can retry - if that field isn't
+ // restored, the retry tap's pendingConfirmationTempFile != tempFile guard silently
+ // no-ops forever, permanently stranding the user on an unresponsive dialog.
+ stubTemplatesFeatureAvailable(true)
+ val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity"))
+ coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info)
+ coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null
+ coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returnsMany
+ listOf(Result.failure(IllegalStateException("disk full")), Result.success(Unit))
+
+ viewModel.onReceived(sourceUriFor("first.cgt"))
+ val effect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
+
+ viewModel.onEvent(
+ ExternalFileInstallUiEvent.ConfirmTemplateInstall(effect.tempFile, effect.suggestedBaseName, overwrite = false),
+ )
+ assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java)
+
+ // Retry tap on the still-open dialog must actually attempt the install again, not
+ // silently no-op.
+ viewModel.onEvent(
+ ExternalFileInstallUiEvent.ConfirmTemplateInstall(effect.tempFile, effect.suggestedBaseName, overwrite = false),
+ )
+ assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowSuccess::class.java)
+ coVerify(exactly = 2) { templateCollectionRepository.installCollection(any(), any(), any()) }
+ }
+
+ @Test
+ fun `cancelling after a failed install still finishes`() =
+ runTest {
+ stubTemplatesFeatureAvailable(true)
+ val info = TemplateCollectionRepository.CollectionInfo(templateNames = listOf("Empty Activity"))
+ coEvery { templateCollectionRepository.inspectCollection(any()) } returns Result.success(info)
+ coEvery { templateCollectionRepository.findExistingCollision(any()) } returns null
+ coEvery { templateCollectionRepository.installCollection(any(), any(), any()) } returns
+ Result.failure(IllegalStateException("disk full"))
+
+ viewModel.onReceived(sourceUriFor("first.cgt"))
+ val effect = viewModel.uiEffect.first() as ExternalFileInstallUiEffect.ShowTemplateInstallConfirmation
+
+ viewModel.onEvent(
+ ExternalFileInstallUiEvent.ConfirmTemplateInstall(effect.tempFile, effect.suggestedBaseName, overwrite = false),
+ )
+ assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java)
+
+ // Cancel/back on the still-open dialog after a failed install must still Finish, not
+ // silently no-op.
+ viewModel.onEvent(ExternalFileInstallUiEvent.IgnoreTemplateInstall(effect.tempFile))
+ assertThat(viewModel.uiEffect.first()).isInstanceOf(ExternalFileInstallUiEffect.Finish::class.java)
+ }
+
+ @Test
+ fun `invalid cgt shows invalid-file error`() =
+ runTest {
+ stubTemplatesFeatureAvailable(true)
+ coEvery { templateCollectionRepository.inspectCollection(any()) } returns
+ Result.failure(IllegalArgumentException("no templates"))
+
+ viewModel.onReceived(sourceUriFor("broken.cgt"))
+
+ val first = viewModel.uiEffect.first()
+ assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ShowError::class.java)
+ }
+
+ @Test
+ fun `onReceived is idempotent per ViewModel instance`() =
+ runTest {
+ stubPluginManagerAvailable(true)
+ val uri = sourceUriFor("my-plugin.cgp")
+
+ viewModel.onReceived(uri)
+ viewModel.onReceived(uri)
+
+ val first = viewModel.uiEffect.first()
+ assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java)
+ verify(exactly = 1) { pluginRepository.isPluginManagerAvailable() }
+ }
+
+ @Test
+ fun `plugin manager becoming available mid-retry still forwards`() =
+ runTest {
+ every { pluginRepository.isPluginManagerAvailable() } returnsMany listOf(false, false, true)
+
+ viewModel.onReceived(sourceUriFor("my-plugin.cgp"))
+
+ val first = viewModel.uiEffect.first()
+ assertThat(first).isInstanceOf(ExternalFileInstallUiEffect.ForwardToPluginManager::class.java)
+ }
+
+ @Test
+ fun `sanitizeBaseName strips filesystem-unsafe characters`() {
+ assertThat(viewModel.sanitizeBaseName("my:templates/v2")).isEqualTo("my_templates_v2")
+ assertThat(viewModel.sanitizeBaseName(" ")).isEqualTo("templates")
+ }
+
+ @Test
+ fun `suggestUniqueBaseName bumps suffix until free`() =
+ runTest {
+ coEvery { templateCollectionRepository.findExistingCollision("foo") } returns "foo"
+ coEvery { templateCollectionRepository.findExistingCollision("foo (2)") } returns "foo (2)"
+ coEvery { templateCollectionRepository.findExistingCollision("foo (3)") } returns null
+
+ val suggested = viewModel.suggestUniqueBaseName("foo")
+
+ assertThat(suggested).isEqualTo("foo (3)")
+ }
+
+ @Test
+ fun `suggestUniqueBaseName gives up after a bounded number of attempts`() =
+ runTest {
+ // A pathological repository that always reports a collision must not hang this
+ // suspend function forever.
+ coEvery { templateCollectionRepository.findExistingCollision(any()) } returns "always-taken"
+
+ val suggested = viewModel.suggestUniqueBaseName("foo")
+
+ assertThat(suggested).isEqualTo("foo (50)")
+ // The give-up candidate itself must actually have been checked for collision - not
+ // returned unverified because the attempt-count bound short-circuited before it.
+ coVerify(exactly = 1) { templateCollectionRepository.findExistingCollision("foo (50)") }
+ }
+
+ private fun stubPluginManagerAvailable(available: Boolean) {
+ every { pluginRepository.isPluginManagerAvailable() } returns available
+ }
+
+ private fun stubTemplatesFeatureAvailable(available: Boolean) {
+ every { templateCollectionRepository.isTemplatesFeatureAvailable() } returns available
+ }
+}
diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt
index fc45eced78..19e208e1c8 100644
--- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt
+++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackEmailHandler.kt
@@ -8,7 +8,6 @@ import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.view.PixelCopy
-import androidx.core.content.FileProvider
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
import com.itsaky.androidide.common.R
@@ -26,16 +25,17 @@ import kotlin.coroutines.suspendCoroutine
class FeedbackEmailHandler(
val context: Context,
) {
-
- companion object {
- const val AUTHORITY_SUFFIX = "providers.fileprovider"
- const val SCREENSHOTS_DIR = "feedback_screenshots"
- const val LOGS_DIR = "feedback_logs"
- const val MAX_EMAIL_BODY_CHARS = 50_000
+ companion object {
+ const val SCREENSHOTS_DIR = "feedback_screenshots"
+ const val LOGS_DIR = "feedback_logs"
+ const val MAX_EMAIL_BODY_CHARS = 50_000
private val log = LoggerFactory.getLogger(FeedbackEmailHandler::class.java)
}
- private fun sanitizeEmailBody(body: String, hasLogAttachment: Boolean = true): String {
+ private fun sanitizeEmailBody(
+ body: String,
+ hasLogAttachment: Boolean = true,
+ ): String {
if (body.length <= MAX_EMAIL_BODY_CHARS) return body
val suffix = if (hasLogAttachment) " See attached file." else ""
return buildString {
@@ -46,9 +46,7 @@ class FeedbackEmailHandler(
}
}
- suspend fun captureAndPrepareScreenshotUri(
- activity: Activity,
- ): Uri? {
+ suspend fun captureAndPrepareScreenshotUri(activity: Activity): Uri? {
val rootView = activity.window?.decorView?.rootView ?: return null
if (rootView.width <= 0 || rootView.height <= 0 || !rootView.isShown) return null
@@ -90,101 +88,99 @@ class FeedbackEmailHandler(
val screenshotsDir = File(context.filesDir, SCREENSHOTS_DIR).apply { mkdirs() }
val timestamp =
SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.getDefault()).format(Date())
- val filename = "Screenshot ${timestamp}.jpg"
+ val filename = "Screenshot $timestamp.jpg"
val screenshotFile = File(screenshotsDir, filename)
FileOutputStream(screenshotFile).use { out ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, out)
}
- val authority = "${context.packageName}.$AUTHORITY_SUFFIX"
- val uri = FileProvider.getUriForFile(context, authority, screenshotFile)
- uri
+ context.fileProviderUriFor(screenshotFile)
} catch (e: Exception) {
log.error(context.getString(R.string.failed_to_save_bitmap_to_file), e)
null
}
- suspend fun getLogUri(
- context: Context,
- logContent: String?,
- ): Uri? =
- withContext(Dispatchers.IO) {
- when {
- logContent.isNullOrEmpty() -> null
-
- else -> {
- try {
- val logsDir = File(context.filesDir, LOGS_DIR).apply { mkdirs() }
- val timestamp =
- SimpleDateFormat(
- "yyyy-MM-dd_HH-mm-ss",
- Locale.getDefault()
- ).format(Date())
- val filename = "Feedback Log ${timestamp}.txt"
- val logFile = File(logsDir, filename)
- logFile.writeText(logContent)
- val authority = "${context.packageName}.$AUTHORITY_SUFFIX"
- val uri = FileProvider.getUriForFile(context, authority, logFile)
- uri
- } catch (e: Exception) {
- log.error(context.getString(R.string.msg_file_creation_failed), e)
- null
- }
- }
- }
- }
-
- fun prepareEmailIntent(
- screenshotUri: Uri?,
- logContentUri: Uri?,
- emailRecipient: String,
- subject: String,
- body: String,
- ): Intent {
- val attachmentUris = mutableListOf()
- screenshotUri?.let { attachmentUris.add(it) }
- logContentUri?.let { attachmentUris.add(it) }
-
- return getIntentBasedOnAttachments(
- emailRecipient = emailRecipient,
- subject = subject,
- body = body,
- attachmentUris = attachmentUris,
- hasLogAttachment = logContentUri != null
- )
- }
-
- fun getIntentBasedOnAttachments(
- emailRecipient: String,
- subject: String,
- body: String,
- attachmentUris: MutableList,
- hasLogAttachment: Boolean = false
- ): Intent {
- val safeBody = sanitizeEmailBody(body, hasLogAttachment)
- return when {
- // No screenshot or log file (if both files failed to be created)
- attachmentUris.isEmpty() -> {
- Intent(Intent.ACTION_SENDTO).apply {
- data = "mailto:".toUri()
- putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient))
- putExtra(Intent.EXTRA_SUBJECT, subject)
- putExtra(Intent.EXTRA_TEXT, safeBody)
- }
- }
- // Screenshot and/or log file
- else -> {
- Intent(Intent.ACTION_SEND_MULTIPLE).apply {
- putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient))
- putExtra(Intent.EXTRA_SUBJECT, subject)
- putExtra(Intent.EXTRA_TEXT, safeBody)
- putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(attachmentUris))
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
- type = "message/rfc822"
- }
- }
- }
- }
+ suspend fun getLogUri(
+ context: Context,
+ logContent: String?,
+ ): Uri? =
+ withContext(Dispatchers.IO) {
+ when {
+ logContent.isNullOrEmpty() -> {
+ null
+ }
+
+ else -> {
+ try {
+ val logsDir = File(context.filesDir, LOGS_DIR).apply { mkdirs() }
+ val timestamp =
+ SimpleDateFormat(
+ "yyyy-MM-dd_HH-mm-ss",
+ Locale.getDefault(),
+ ).format(Date())
+ val filename = "Feedback Log $timestamp.txt"
+ val logFile = File(logsDir, filename)
+ logFile.writeText(logContent)
+ context.fileProviderUriFor(logFile)
+ } catch (e: Exception) {
+ log.error(context.getString(R.string.msg_file_creation_failed), e)
+ null
+ }
+ }
+ }
+ }
+
+ fun prepareEmailIntent(
+ screenshotUri: Uri?,
+ logContentUri: Uri?,
+ emailRecipient: String,
+ subject: String,
+ body: String,
+ ): Intent {
+ val attachmentUris = mutableListOf()
+ screenshotUri?.let { attachmentUris.add(it) }
+ logContentUri?.let { attachmentUris.add(it) }
+
+ return getIntentBasedOnAttachments(
+ emailRecipient = emailRecipient,
+ subject = subject,
+ body = body,
+ attachmentUris = attachmentUris,
+ hasLogAttachment = logContentUri != null,
+ )
+ }
+
+ fun getIntentBasedOnAttachments(
+ emailRecipient: String,
+ subject: String,
+ body: String,
+ attachmentUris: MutableList,
+ hasLogAttachment: Boolean = false,
+ ): Intent {
+ val safeBody = sanitizeEmailBody(body, hasLogAttachment)
+ return when {
+ // No screenshot or log file (if both files failed to be created)
+ attachmentUris.isEmpty() -> {
+ Intent(Intent.ACTION_SENDTO).apply {
+ data = "mailto:".toUri()
+ putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient))
+ putExtra(Intent.EXTRA_SUBJECT, subject)
+ putExtra(Intent.EXTRA_TEXT, safeBody)
+ }
+ }
+ // Screenshot and/or log file
+ else -> {
+ Intent(Intent.ACTION_SEND_MULTIPLE).apply {
+ putExtra(Intent.EXTRA_EMAIL, arrayOf(emailRecipient))
+ putExtra(Intent.EXTRA_SUBJECT, subject)
+ putExtra(Intent.EXTRA_TEXT, safeBody)
+ putParcelableArrayListExtra(Intent.EXTRA_STREAM, ArrayList(attachmentUris))
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ type = "message/rfc822"
+ }
+ }
+ }
+ }
}
diff --git a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt
index 28f07dc935..b3b749e596 100644
--- a/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt
+++ b/common/src/main/java/com/itsaky/androidide/utils/FeedbackManager.kt
@@ -15,7 +15,6 @@ import android.view.View
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.FileProvider
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
@@ -41,29 +40,32 @@ object FeedbackManager {
private const val EMAIL_SUPPORT = "feedback@appdevforall.org"
private val logger = LoggerFactory.getLogger(FeedbackManager::class.java)
- /**
- * Shows the feedback dialog and handles sending feedback email.
- *
- * @param activity The context from which feedback is being sent
- */
- fun showFeedbackDialog(activity: AppCompatActivity, logContent: String?) {
- val builder = DialogUtils.newMaterialDialogBuilder(activity)
+ /**
+ * Shows the feedback dialog and handles sending feedback email.
+ *
+ * @param activity The context from which feedback is being sent
+ */
+ fun showFeedbackDialog(
+ activity: AppCompatActivity,
+ logContent: String?,
+ ) {
+ val builder = DialogUtils.newMaterialDialogBuilder(activity)
- builder
- .setTitle(R.string.title_alert)
- .setMessage(
- HtmlCompat.fromHtml(
- activity.getString(R.string.email_feedback_warning_prompt),
- HtmlCompat.FROM_HTML_MODE_COMPACT,
- ),
- ).setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() }
- .setPositiveButton(android.R.string.ok) { dialog, _ ->
- dialog.dismiss()
- sendFeedbackWithAttachments(activity, logContent)
- }.show()
- }
+ builder
+ .setTitle(R.string.title_alert)
+ .setMessage(
+ HtmlCompat.fromHtml(
+ activity.getString(R.string.email_feedback_warning_prompt),
+ HtmlCompat.FROM_HTML_MODE_COMPACT,
+ ),
+ ).setNegativeButton(android.R.string.cancel) { dialog, _ -> dialog.dismiss() }
+ .setPositiveButton(android.R.string.ok) { dialog, _ ->
+ dialog.dismiss()
+ sendFeedbackWithAttachments(activity, logContent)
+ }.show()
+ }
- /**
+ /**
* Shows a simple contact dialog as fallback when email intents fail.
* Uses the same title, message, and button text as the existing contact dialog.
*/
@@ -92,19 +94,20 @@ object FeedbackManager {
customSubject: String,
metadata: String,
includeScreenshot: Boolean = true,
- shareActivityResultLauncher: ActivityResultLauncher? = null
- ) {
- val message = buildString {
- append(metadata)
- append(
- context.getString(
- R.string.feedback_device_info,
- BasicBuildInfo.formatVersion(),
- Build.VERSION.RELEASE,
- "${Build.MANUFACTURER} ${Build.MODEL}",
- )
- )
- }
+ shareActivityResultLauncher: ActivityResultLauncher? = null,
+ ) {
+ val message =
+ buildString {
+ append(metadata)
+ append(
+ context.getString(
+ R.string.feedback_device_info,
+ BasicBuildInfo.formatVersion(),
+ Build.VERSION.RELEASE,
+ "${Build.MANUFACTURER} ${Build.MODEL}",
+ ),
+ )
+ }
if (includeScreenshot) {
captureScreenshot(context) { screenshotFile ->
@@ -113,7 +116,7 @@ object FeedbackManager {
customSubject,
message,
screenshotFile,
- shareActivityResultLauncher
+ shareActivityResultLauncher,
)
}
} else {
@@ -122,7 +125,7 @@ object FeedbackManager {
customSubject,
message,
null,
- shareActivityResultLauncher
+ shareActivityResultLauncher,
)
}
}
@@ -132,51 +135,49 @@ object FeedbackManager {
subject: String,
message: String,
attachmentFile: File?,
- shareActivityResultLauncher: ActivityResultLauncher?
+ shareActivityResultLauncher: ActivityResultLauncher?,
) {
runCatching {
- val intent = if (attachmentFile != null) {
- Intent(Intent.ACTION_SEND).apply {
- type = "message/rfc822"
- putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT))
- putExtra(Intent.EXTRA_SUBJECT, subject)
- putExtra(Intent.EXTRA_TEXT, message)
-
- val uri = FileProvider.getUriForFile(
- context,
- "${context.packageName}.providers.fileprovider",
- attachmentFile
- )
- putExtra(Intent.EXTRA_STREAM, uri)
- addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
-
- if (context !is Activity) {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ val intent =
+ if (attachmentFile != null) {
+ Intent(Intent.ACTION_SEND).apply {
+ type = "message/rfc822"
+ putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT))
+ putExtra(Intent.EXTRA_SUBJECT, subject)
+ putExtra(Intent.EXTRA_TEXT, message)
+
+ val uri = context.fileProviderUriFor(attachmentFile)
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+
+ if (context !is Activity) {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
}
- }
- } else {
- Intent(Intent.ACTION_SENDTO).apply {
- data = "mailto:".toUri()
- putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT))
- putExtra(Intent.EXTRA_SUBJECT, subject)
- putExtra(Intent.EXTRA_TEXT, message)
-
- if (context !is Activity) {
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ } else {
+ Intent(Intent.ACTION_SENDTO).apply {
+ data = "mailto:".toUri()
+ putExtra(Intent.EXTRA_EMAIL, arrayOf(EMAIL_SUPPORT))
+ putExtra(Intent.EXTRA_SUBJECT, subject)
+ putExtra(Intent.EXTRA_TEXT, message)
+
+ if (context !is Activity) {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
}
}
- }
launchIntentChooser(
intent,
context.getString(R.string.send_feedback),
context,
- shareActivityResultLauncher
+ shareActivityResultLauncher,
)
}.recoverCatching {
- val fallbackIntent = Intent(Intent.ACTION_SENDTO).apply {
- data = "mailto:${EMAIL_SUPPORT}?subject=${Uri.encode(subject)}&body=${Uri.encode(message)}".toUri()
- }
+ val fallbackIntent =
+ Intent(Intent.ACTION_SENDTO).apply {
+ data = "mailto:${EMAIL_SUPPORT}?subject=${Uri.encode(subject)}&body=${Uri.encode(message)}".toUri()
+ }
context.startActivity(fallbackIntent)
}.onFailure {
logger.error("Failed to send feedback with attachment", it)
@@ -184,8 +185,10 @@ object FeedbackManager {
}
}
-
- fun captureScreenshot(context: Context, callback: (File?) -> Unit) {
+ fun captureScreenshot(
+ context: Context,
+ callback: (File?) -> Unit,
+ ) {
val activity = context as? AppCompatActivity
if (activity == null) {
logger.warn("Cannot capture screenshot: Context is not an Activity")
@@ -194,37 +197,36 @@ object FeedbackManager {
}
val rootView = activity.window.decorView.rootView
- val screenshotFile = createScreenshotFile(context) ?: run {
- callback(null)
- return
- }
- captureWithPixelCopy(activity, rootView, screenshotFile, callback)
- }
-
-
- private fun createScreenshotFile(context: Context): File? {
- return runCatching {
- val screenshotDir = File(context.cacheDir, "screenshots").apply {
- if (!exists()) mkdirs()
+ val screenshotFile =
+ createScreenshotFile(context) ?: run {
+ callback(null)
+ return
}
+ captureWithPixelCopy(activity, rootView, screenshotFile, callback)
+ }
+
+ private fun createScreenshotFile(context: Context): File? =
+ runCatching {
+ val screenshotDir =
+ File(context.cacheDir, "screenshots").apply {
+ if (!exists()) mkdirs()
+ }
val timestamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
File(screenshotDir, "screenshot_$timestamp.png")
}.onFailure {
logger.error("Failed to create screenshot file", it)
}.getOrNull()
- }
private fun captureWithPixelCopy(
activity: AppCompatActivity,
rootView: View,
screenshotFile: File,
- callback: (File?) -> Unit
+ callback: (File?) -> Unit,
) {
+ var bitmap: Bitmap? = null
- var bitmap: Bitmap? = null
-
- try {
- bitmap = createBitmap(rootView.width, rootView.height)
+ try {
+ bitmap = createBitmap(rootView.width, rootView.height)
val locationOfViewInWindow = IntArray(2)
rootView.getLocationInWindow(locationOfViewInWindow)
@@ -234,51 +236,54 @@ object FeedbackManager {
locationOfViewInWindow[0],
locationOfViewInWindow[1],
locationOfViewInWindow[0] + rootView.width,
- locationOfViewInWindow[1] + rootView.height
+ locationOfViewInWindow[1] + rootView.height,
),
bitmap,
{ result ->
if (result == PixelCopy.SUCCESS) {
- activity.lifecycleScope.launch {
- saveScreenshot(bitmap, screenshotFile, callback)
- }
- } else {
- logger.error("PixelCopy failed with result code: $result")
- bitmap.recycle()
- callback(null)
- }
+ activity.lifecycleScope.launch {
+ saveScreenshot(bitmap, screenshotFile, callback)
+ }
+ } else {
+ logger.error("PixelCopy failed with result code: $result")
+ bitmap.recycle()
+ callback(null)
+ }
},
- Handler(Looper.getMainLooper())
+ Handler(Looper.getMainLooper()),
)
} catch (e: Exception) {
logger.error("PixelCopy exception, falling back to Canvas", e)
- bitmap?.recycle()
- callback(null)
+ bitmap?.recycle()
+ callback(null)
}
}
-
- private suspend fun saveScreenshot(bitmap: Bitmap, file: File, callback: (File?) -> Unit) {
- val result = withContext(Dispatchers.IO) {
- runCatching {
- FileOutputStream(file).use { out ->
- bitmap.compress(Bitmap.CompressFormat.PNG, 90, out)
- }
- file
- }.onFailure {
- logger.error("Failed to save screenshot", it)
- }.getOrNull()
- }
- bitmap.recycle()
- callback(result)
- }
-
+ private suspend fun saveScreenshot(
+ bitmap: Bitmap,
+ file: File,
+ callback: (File?) -> Unit,
+ ) {
+ val result =
+ withContext(Dispatchers.IO) {
+ runCatching {
+ FileOutputStream(file).use { out ->
+ bitmap.compress(Bitmap.CompressFormat.PNG, 90, out)
+ }
+ file
+ }.onFailure {
+ logger.error("Failed to save screenshot", it)
+ }.getOrNull()
+ }
+ bitmap.recycle()
+ callback(result)
+ }
private fun launchIntentChooser(
intent: Intent,
chooserTitle: String,
context: Context,
- shareActivityResultLauncher: ActivityResultLauncher?
+ shareActivityResultLauncher: ActivityResultLauncher?,
) {
val chooser = Intent.createChooser(intent, chooserTitle)
shareActivityResultLauncher?.launch(chooser) ?: context.startActivity(chooser)
@@ -294,74 +299,76 @@ object FeedbackManager {
else -> "Unknown Screen"
}
- private fun sendFeedbackWithAttachments(
- activity: AppCompatActivity,
- logContent: String?
- ) {
- activity.lifecycleScope.launch {
- val handler = FeedbackEmailHandler(activity)
+ private fun sendFeedbackWithAttachments(
+ activity: AppCompatActivity,
+ logContent: String?,
+ ) {
+ activity.lifecycleScope.launch {
+ val handler = FeedbackEmailHandler(activity)
+
+ val screenshotUri = handler.captureAndPrepareScreenshotUri(activity)
+ val logContentUri = handler.getLogUri(activity, logContent)
+
+ val feedbackRecipient = activity.getString(R.string.feedback_email)
+ val feedbackSubject =
+ activity.getString(R.string.feedback_subject, getCurrentScreenName(activity))
+ val stackTraceSection =
+ logContent?.trim().takeIf { it?.isNotEmpty() == true }
+ ?: activity.getString(R.string.feedback_stack_trace_unavailable)
+ val feedbackBody =
+ buildString {
+ append(
+ activity.getString(
+ R.string.feedback_device_info,
+ BasicBuildInfo.formatVersion(),
+ Build.VERSION.RELEASE,
+ "${Build.MANUFACTURER} ${Build.MODEL}",
+ ),
+ )
+ append(
+ activity.getString(
+ R.string.feedback_message,
+ stackTraceSection,
+ ),
+ )
+ }
- val screenshotUri = handler.captureAndPrepareScreenshotUri(activity)
- val logContentUri = handler.getLogUri(activity, logContent)
+ val emailIntent =
+ handler.prepareEmailIntent(
+ screenshotUri,
+ logContentUri,
+ feedbackRecipient,
+ feedbackSubject,
+ feedbackBody,
+ )
- val feedbackRecipient = activity.getString(R.string.feedback_email)
- val feedbackSubject =
- activity.getString(R.string.feedback_subject, getCurrentScreenName(activity))
- val stackTraceSection =
- logContent?.trim().takeIf { it?.isNotEmpty() == true }
- ?: activity.getString(R.string.feedback_stack_trace_unavailable)
- val feedbackBody =
- buildString {
- append(
- activity.getString(
- R.string.feedback_device_info,
- BasicBuildInfo.formatVersion(),
- Build.VERSION.RELEASE,
- "${Build.MANUFACTURER} ${Build.MODEL}",
- ),
- )
- append(
- activity.getString(
- R.string.feedback_message,
- stackTraceSection,
- ),
- )
- }
+ runCatching {
+ activity.startActivity(emailIntent)
+ }.onFailure { e ->
+ when {
+ e is ActivityNotFoundException -> {
+ Toast.makeText(activity, R.string.no_email_apps, Toast.LENGTH_LONG).show()
+ }
- val emailIntent =
- handler.prepareEmailIntent(
- screenshotUri,
- logContentUri,
- feedbackRecipient,
- feedbackSubject,
- feedbackBody,
- )
+ e is TransactionTooLargeException ||
+ (e is RuntimeException && e.cause is TransactionTooLargeException) -> {
+ logger.error("Intent transaction failed: Data too large", e)
+ Toast.makeText(activity, R.string.msg_feedback_log_too_long, Toast.LENGTH_LONG).show()
+ }
- runCatching {
- activity.startActivity(emailIntent)
- }.onFailure { e ->
- when {
- e is ActivityNotFoundException -> {
- Toast.makeText(activity, R.string.no_email_apps, Toast.LENGTH_LONG).show()
- }
- e is TransactionTooLargeException ||
- (e is RuntimeException && e.cause is TransactionTooLargeException) -> {
- logger.error("Intent transaction failed: Data too large", e)
- Toast.makeText(activity, R.string.msg_feedback_log_too_long, Toast.LENGTH_LONG).show()
- }
- else -> {
- logger.error("Intent transaction failed: Unknown error", e)
- EventBus.getDefault().post(
- ReportCaughtExceptionEvent(
- throwable = e,
- message = "Feedback email intent failed",
- extras = mapOf("screen" to getCurrentScreenName(activity))
- )
- )
- Toast.makeText(activity, R.string.unknown_error, Toast.LENGTH_LONG).show()
- }
- }
- }
- }
- }
+ else -> {
+ logger.error("Intent transaction failed: Unknown error", e)
+ EventBus.getDefault().post(
+ ReportCaughtExceptionEvent(
+ throwable = e,
+ message = "Feedback email intent failed",
+ extras = mapOf("screen" to getCurrentScreenName(activity)),
+ ),
+ )
+ Toast.makeText(activity, R.string.unknown_error, Toast.LENGTH_LONG).show()
+ }
+ }
+ }
+ }
+ }
}
diff --git a/common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt
new file mode 100644
index 0000000000..dbce28665b
--- /dev/null
+++ b/common/src/main/java/com/itsaky/androidide/utils/FileProviderUtils.kt
@@ -0,0 +1,24 @@
+package com.itsaky.androidide.utils
+
+import android.content.Context
+import android.net.Uri
+import androidx.core.content.FileProvider
+import java.io.File
+
+const val FILE_PROVIDER_AUTHORITY_SUFFIX = "providers.fileprovider"
+
+/**
+ * This app's [androidx.core.content.FileProvider] authority for a given [packageName] - shared so
+ * every caller that mints or checks a `content://` Uri against it agrees on the same string, even
+ * callers (e.g. a ViewModel) that only hold a package name rather than a full [Context].
+ */
+fun fileProviderAuthorityFor(packageName: String): String = "$packageName.$FILE_PROVIDER_AUTHORITY_SUFFIX"
+
+/**
+ * This app's [androidx.core.content.FileProvider] authority - shared so every caller that mints
+ * or checks a `content://` Uri against it agrees on the same string.
+ */
+fun Context.fileProviderAuthority(): String = fileProviderAuthorityFor(packageName)
+
+/** Mints a `content://` Uri for [file] via this app's [androidx.core.content.FileProvider]. */
+fun Context.fileProviderUriFor(file: File): Uri = FileProvider.getUriForFile(this, fileProviderAuthority(), file)
diff --git a/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt
index 532fd8f59e..67bfcec141 100644
--- a/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt
+++ b/common/src/main/java/com/itsaky/androidide/utils/FlashbarActivityUtils.kt
@@ -34,13 +34,19 @@ import com.itsaky.androidide.tasks.runOnUiThread
import com.itsaky.androidide.utils.FlashType.ERROR
import com.itsaky.androidide.utils.FlashType.INFO
import com.itsaky.androidide.utils.FlashType.SUCCESS
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeoutOrNull
const val DURATION_SHORT = 2000L
const val DURATION_LONG = 3500L
const val DURATION_INDEFINITE = Flashbar.DURATION_INDEFINITE
+/** Safety net for [Flashbar.OnBarShowListener.onShown] never firing - callers awaiting it are
+ * never blocked indefinitely (e.g. if there's no foreground activity to actually show a bar). */
+private const val FLASH_SHOWN_TIMEOUT_MS = 3000L
+
val COLOR_SUCCESS = Color.parseColor("#4CAF50")
val COLOR_ERROR = Color.parseColor("#f44336")
const val COLOR_INFO = Color.DKGRAY
@@ -54,35 +60,45 @@ private fun Flashbar.Builder.applyIcon(iconType: IconType): Flashbar.Builder =
IconType.INFO -> this.infoIcon()
}
-private fun Activity.showFlashBar(
+/**
+ * Builds and configures a Flashbar for [msg]/[iconType] (icon, and - for an indefinite error - the
+ * dismiss button), without showing it yet. Shared by [showFlashBar] and [showFlashBarAwaitShown]
+ * so their setup can't silently diverge. Returns `null` for a `null` [msg] (nothing to show).
+ */
+private fun Activity.configureFlashbar(
msg: Any?,
iconType: IconType,
- gravity: Flashbar.Gravity = TOP,
- duration: Long = Flashbar.DURATION_SHORT,
-) {
- val builder = flashbarBuilder(gravity, duration)
- .applyIcon(iconType)
+ gravity: Flashbar.Gravity,
+ duration: Long,
+): Flashbar.Builder? {
+ if (msg == null) return null
+ if (msg !is Int && msg !is String) {
+ throw IllegalArgumentException("Message must be String or Int resource")
+ }
- // Add a close button if the flashbar is an indefinite error
- if (duration == DURATION_INDEFINITE && iconType == IconType.ERROR) {
- builder.positiveActionText(getString(R.string.dismiss))
- builder.positiveActionTapListener { it.dismiss() }
- }
+ val builder = flashbarBuilder(gravity, duration).applyIcon(iconType)
+
+ // Add a close button if the flashbar is an indefinite error
+ if (duration == DURATION_INDEFINITE && iconType == IconType.ERROR) {
+ builder.positiveActionText(getString(R.string.dismiss))
+ builder.positiveActionTapListener { it.dismiss() }
+ }
when (msg) {
- null -> return
- is Int ->
- builder
- .message(msg)
- .showOnUiThread()
-
- is String ->
- builder
- .message(msg)
- .showOnUiThread()
-
- else -> throw IllegalArgumentException("Message must be String or Int resource")
+ is Int -> builder.message(msg)
+ is String -> builder.message(msg)
}
+
+ return builder
+}
+
+private fun Activity.showFlashBar(
+ msg: Any?,
+ iconType: IconType,
+ gravity: Flashbar.Gravity = TOP,
+ duration: Long = Flashbar.DURATION_SHORT,
+) {
+ configureFlashbar(msg, iconType, gravity, duration)?.showOnUiThread()
}
@JvmOverloads
@@ -128,6 +144,44 @@ fun Activity.flashError(msg: String?) = showFlashBar(msg, IconType.ERROR, durati
fun Activity.flashInfo(msg: String?) = showFlashBar(msg, IconType.INFO)
+/**
+ * Like [showFlashBar], but suspends until the bar's entrance animation has actually finished (or
+ * [FLASH_SHOWN_TIMEOUT_MS] elapses) instead of firing-and-forgetting - for callers (e.g. a
+ * one-shot screen about to finish()) that need the message to be visible before proceeding,
+ * rather than guessing a fixed delay that may or may not outlast the real animation.
+ */
+private suspend fun Activity.showFlashBarAwaitShown(
+ msg: Any?,
+ iconType: IconType,
+ gravity: Flashbar.Gravity = TOP,
+ duration: Long = Flashbar.DURATION_SHORT,
+) {
+ val builder = configureFlashbar(msg, iconType, gravity, duration) ?: return
+
+ val shown = CompletableDeferred()
+ builder.barShowListener(
+ object : Flashbar.OnBarShowListener {
+ override fun onShowing(bar: Flashbar) = Unit
+
+ override fun onShowProgress(
+ bar: Flashbar,
+ progress: Float,
+ ) = Unit
+
+ override fun onShown(bar: Flashbar) {
+ shown.complete(Unit)
+ }
+ },
+ )
+
+ runOnUiThread { builder.build().show() }
+ withTimeoutOrNull(FLASH_SHOWN_TIMEOUT_MS) { shown.await() }
+}
+
+suspend fun Activity.flashSuccessAwaitShown(msg: String?) = showFlashBarAwaitShown(msg, IconType.SUCCESS)
+
+suspend fun Activity.flashErrorAwaitShown(msg: String?) = showFlashBarAwaitShown(msg, IconType.ERROR, duration = DURATION_INDEFINITE)
+
fun Activity.flashSuccess(
@StringRes msg: Int,
) = showFlashBar(msg, IconType.SUCCESS)
diff --git a/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt b/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt
index 6028135fa9..1d951a5495 100644
--- a/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt
+++ b/common/src/main/java/com/itsaky/androidide/utils/FlashbarUtils.kt
@@ -48,10 +48,20 @@ fun flashSuccess(
withActivity { flashSuccess(msg) }
}
+/** Suspends until the success bar has actually finished its entrance animation - see [Activity.flashSuccessAwaitShown]. */
+suspend fun flashSuccessAwaitShown(msg: String?) {
+ withActivitySuspend { flashSuccessAwaitShown(msg) }
+}
+
fun flashError(msg: String?) {
withActivity { flashError(msg) }
}
+/** Suspends until the error bar has actually finished its entrance animation - see [Activity.flashErrorAwaitShown]. */
+suspend fun flashErrorAwaitShown(msg: String?) {
+ withActivitySuspend { flashErrorAwaitShown(msg) }
+}
+
fun flashError(
@StringRes msg: Int,
) {
@@ -78,6 +88,15 @@ private inline fun withActivity(action: Activity.() -> T?): T? =
null
}
+private suspend inline fun withActivitySuspend(crossinline action: suspend Activity.() -> Unit) {
+ val activity = BaseApplication.baseInstance.foregroundActivity
+ if (activity == null) {
+ ILogger.ROOT.warn("Cannot show flashbar message. Cannot get top activity.")
+ return
+ }
+ activity.action()
+}
+
/** The type of flashbar message. */
enum class FlashType {
ERROR,
diff --git a/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt b/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt
new file mode 100644
index 0000000000..f9f10926c1
--- /dev/null
+++ b/common/src/test/java/com/itsaky/androidide/utils/FileProviderUtilsTest.kt
@@ -0,0 +1,31 @@
+package com.itsaky.androidide.utils
+
+import android.content.Context
+import com.google.common.truth.Truth.assertThat
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.Test
+
+/** Every `content://` Uri this app mints or checks must agree on the same FileProvider authority string. */
+class FileProviderUtilsTest {
+ @Test
+ fun `fileProviderAuthorityFor appends the fixed suffix to the package name`() {
+ assertThat(fileProviderAuthorityFor("com.itsaky.androidide"))
+ .isEqualTo("com.itsaky.androidide.providers.fileprovider")
+ }
+
+ @Test
+ fun `fileProviderAuthorityFor is stable across different package names`() {
+ assertThat(fileProviderAuthorityFor("com.example.other"))
+ .isEqualTo("com.example.other.providers.fileprovider")
+ }
+
+ @Test
+ fun `Context fileProviderAuthority delegates to the context's package name`() {
+ val context = mockk()
+ every { context.packageName } returns "com.itsaky.androidide"
+
+ assertThat(context.fileProviderAuthority())
+ .isEqualTo(fileProviderAuthorityFor("com.itsaky.androidide"))
+ }
+}
diff --git a/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt b/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt
index d7bc7f5694..4e67e29197 100644
--- a/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt
+++ b/composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt
@@ -88,3 +88,6 @@ const val GRADLE_API_NAME_JAR_BR = "${GRADLE_API_NAME_JAR}.br"
const val TEMPLATE_ARCHIVE_EXTENSION = "cgt"
const val TEMPLATE_CORE_ARCHIVE = "core.$TEMPLATE_ARCHIVE_EXTENSION"
const val TEMPLATE_CORE_ARCHIVE_BR = "${TEMPLATE_CORE_ARCHIVE}.br"
+
+// Plugin archive
+const val PLUGIN_ARCHIVE_EXTENSION = "cgp"
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 9c4e15649b..5648a02daf 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -137,6 +137,7 @@ compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview"
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-material3 = { module = "androidx.compose.material3:material3" }
compose-activity = { module = "androidx.activity:activity-compose", version = "1.8.2" }
+compose-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" }
# Firebase
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebase-bom" }
diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt
index 02a0571d1f..48cd034416 100644
--- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt
+++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt
@@ -50,6 +50,7 @@ object TooltipTag {
const val PREFS_EDITOR_XML = "prefs.editor.xml"
const val PREFS_DEVELOPER = "prefs.developer"
const val PLUGIN_MANAGER = "plugin.manager"
+ const val EXTERNAL_FILE_INSTALL = "external.file.install"
const val TEMPLATE_TABBED_ACTIVITY = "template.tabbed.activity"
const val TEMPLATE_LEGACY_PROJECT = "template.legacy.project"
const val TEMPLATE_EMPTY_ACTIVITY = "template.empty.activity"
diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt
index ba895ebd7c..50d401dfd6 100644
--- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt
+++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt
@@ -84,6 +84,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
+import org.adfa.constants.PLUGIN_ARCHIVE_EXTENSION
import java.io.File
import java.util.concurrent.ConcurrentHashMap
@@ -385,7 +386,7 @@ class PluginManager private constructor(
val pluginFiles =
pluginsDir.listFiles { file ->
- file.isFile && file.name.endsWith(".cgp", ignoreCase = true)
+ file.isFile && file.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true)
} ?: return@withContext
logger.info("Found ${pluginFiles.size} plugin files")
@@ -488,7 +489,7 @@ class PluginManager private constructor(
return Result.failure(IllegalArgumentException(error))
}
- if (!pluginFile.name.endsWith(".cgp", ignoreCase = true)) {
+ if (!pluginFile.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true)) {
val error = "Only CGP plugins are supported. File: ${pluginFile.name}"
logger.error(error)
return Result.failure(IllegalArgumentException(error))
@@ -840,7 +841,7 @@ class PluginManager private constructor(
incomingFile: File,
existingPluginId: String,
): Boolean {
- val existingFile = File(pluginsDir, "$existingPluginId.cgp")
+ val existingFile = File(pluginsDir, "$existingPluginId.$PLUGIN_ARCHIVE_EXTENSION")
val incomingSig = PluginLoader(context, incomingFile).getSignatureHash()
val existingSig = PluginLoader(context, existingFile).getSignatureHash()
if (incomingSig == null || existingSig == null) {
@@ -870,7 +871,7 @@ class PluginManager private constructor(
// Find and delete the plugin file (CGP)
val pluginFiles =
pluginsDir.listFiles { file ->
- file.isFile && file.name.endsWith(".cgp", ignoreCase = true)
+ file.isFile && file.name.endsWith(".$PLUGIN_ARCHIVE_EXTENSION", ignoreCase = true)
}
if (pluginFiles == null || pluginFiles.isEmpty()) {
diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml
index 97d441fbbb..15fa7d11b0 100644
--- a/resources/src/main/res/values/strings.xml
+++ b/resources/src/main/res/values/strings.xml
@@ -1016,6 +1016,7 @@
Error
Warning
Information
+ Show help
Quick run
@@ -1089,6 +1090,20 @@
https://www.appdevforall.org/contribute/
%1$s: %2$s
+ Could not read the file. It may be corrupted or unavailable.
+ Unsupported file type. Only .cgp and .cgt files can be opened this way.
+ IDE setup has not finished yet. Please try again once setup completes.
+ Install Template Collection
+ Install \'%1$s\' with the following templates: %2$s?
+ Template Collection Already Installed
+ A template collection named \'%1$s\' is already installed. The new one contains: %2$s. What would you like to do?
+ Overwrite
+ Rename & Install
+ New collection name
+ "%1$s" installed successfully
+ Invalid or corrupted template collection file.
+ Failed to install template collection: %1$s
+
\n\nProject creation finished with warnings/errors. Open IDE Logs for details.
diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt
index 1acbe6a53d..2ba4aa2d76 100644
--- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt
+++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/TemplateProviderImpl.kt
@@ -24,10 +24,8 @@ import com.itsaky.androidide.templates.R
import com.itsaky.androidide.templates.Template
import com.itsaky.androidide.templates.impl.zip.ZipRecipeExecutor
import com.itsaky.androidide.templates.impl.zip.ZipTemplateReader
-
-import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION
import com.itsaky.androidide.utils.Environment.TEMPLATES_DIR
-
+import org.adfa.constants.TEMPLATE_ARCHIVE_EXTENSION
import org.slf4j.LoggerFactory
import java.util.zip.ZipFile
@@ -39,56 +37,55 @@ import java.util.zip.ZipFile
@Suppress("unused")
@AutoService(ITemplateProvider::class)
class TemplateProviderImpl : ITemplateProvider {
+ companion object {
+ private val log = LoggerFactory.getLogger(TemplateProviderImpl::class.java)
+ }
- companion object {
- private val log = LoggerFactory.getLogger(TemplateProviderImpl::class.java)
- }
-
- private val templates = mutableMapOf>()
- val warnings: MutableList = mutableListOf()
+ private val templates = mutableMapOf>()
+ val warnings: MutableList = mutableListOf()
- init {
- reload()
- }
+ init {
+ reload()
+ }
- private fun initializeTemplates() {
- val folder = TEMPLATES_DIR
- val list = folder.listFiles { file -> file.extension == TEMPLATE_ARCHIVE_EXTENSION } ?: return
+ private fun initializeTemplates() {
+ val folder = TEMPLATES_DIR
+ val list = folder.listFiles { file -> file.extension.equals(TEMPLATE_ARCHIVE_EXTENSION, ignoreCase = true) } ?: return
- for (zipFile in list) {
- try {
- val zipTemplates = ZipTemplateReader.read(zipFile, warnings) { json, params, path, data, defModule ->
- ZipRecipeExecutor({ ZipFile(zipFile) }, json, params, path, data, defModule)
- }
+ for (zipFile in list) {
+ try {
+ val zipTemplates =
+ ZipTemplateReader.read(zipFile, warnings) { json, params, path, data, defModule ->
+ ZipRecipeExecutor({ ZipFile(zipFile) }, json, params, path, data, defModule)
+ }
- for (t in zipTemplates) {
- templates[t.templateId] = t
- }
- } catch (e: Exception) {
- warnings.add(TemplateWarning(
- R.string.template_read_error_archive_load,
- listOf(zipFile, e.message)))
- log.error("Failed to load template from archive: $zipFile", e)
- }
- }
- }
+ for (t in zipTemplates) {
+ templates[t.templateId] = t
+ }
+ } catch (e: Exception) {
+ warnings.add(
+ TemplateWarning(
+ R.string.template_read_error_archive_load,
+ listOf(zipFile, e.message),
+ ),
+ )
+ log.error("Failed to load template from archive: $zipFile", e)
+ }
+ }
+ }
- override fun getTemplates(): List> {
- return ImmutableList.copyOf(templates.values)
- }
+ override fun getTemplates(): List> = ImmutableList.copyOf(templates.values)
- override fun getTemplate(templateId: String): Template<*>? {
- return templates[templateId]
- }
+ override fun getTemplate(templateId: String): Template<*>? = templates[templateId]
- override fun reload() {
- release()
- warnings.clear()
- initializeTemplates()
- }
+ override fun reload() {
+ release()
+ warnings.clear()
+ initializeTemplates()
+ }
- override fun release() {
- templates.forEach { it.value.release() }
- templates.clear()
- }
+ override fun release() {
+ templates.forEach { it.value.release() }
+ templates.clear()
+ }
}