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() + } }