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 298e36e698..53799d007f 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/PluginManagerActivity.kt @@ -36,6 +36,9 @@ import com.itsaky.androidide.utils.DialogUtils.showRestartPrompt import com.itsaky.androidide.viewmodels.PluginManagerViewModel import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag +import android.content.ClipData +import android.content.ClipboardManager +import com.itsaky.androidide.utils.DURATION_INDEFINITE import kotlinx.coroutines.launch import org.koin.androidx.viewmodel.ext.android.viewModel @@ -229,10 +232,20 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { private fun handleUiEffect(effect: PluginManagerUiEffect) { when (effect) { is PluginManagerUiEffect.ShowError -> { - flashbarBuilder(duration = 5000L) + val errorMessage = getString(effect.messageResId, *effect.formatArgs.toTypedArray()) + val builder = flashbarBuilder(duration = if (effect.formatArgs.isEmpty()) 5000L else DURATION_INDEFINITE) .errorIcon() - .message(getString(effect.messageResId, *effect.formatArgs.toTypedArray())) - .showOnUiThread() + .message(errorMessage) + if (effect.formatArgs.isNotEmpty()) { + builder + .positiveActionText(R.string.copy) + .positiveActionTapListener { bar -> + (getSystemService(ClipboardManager::class.java)) + ?.setPrimaryClip(ClipData.newPlainText(getString(R.string.msg_plugin_error_clip_label), errorMessage)) + bar.dismiss() + } + } + builder.showOnUiThread() } is PluginManagerUiEffect.ShowSuccess -> { flashSuccess(getString(effect.messageResId)) @@ -249,6 +262,9 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { is PluginManagerUiEffect.ShowRestartPrompt -> { showRestartPrompt(this, cancelable = false) } + is PluginManagerUiEffect.ShowOverwriteConfirmation -> { + showOverwriteConfirmation(effect) + } } } @@ -277,6 +293,26 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() { .show() } + private fun showOverwriteConfirmation(effect: PluginManagerUiEffect.ShowOverwriteConfirmation) { + MaterialAlertDialogBuilder(this) + .setTitle(R.string.title_plugin_already_installed) + .setMessage( + getString( + R.string.msg_plugin_overwrite_confirm, + effect.existing.metadata.name, + effect.existing.metadata.version, + effect.incomingMetadata.version + ) + ) + .setPositiveButton(R.string.replace) { _, _ -> + viewModel.onEvent( + PluginManagerUiEvent.ConfirmOverwrite(effect.uri, effect.deleteSourceAfterInstall) + ) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } + private fun showUninstallConfirmation(plugin: PluginInfo) { MaterialAlertDialogBuilder(this) .setTitle("Uninstall Plugin") diff --git a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepository.kt b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepository.kt index 66618d9f9e..88a32ca7e1 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepository.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepository.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.repositories import android.net.Uri import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.plugins.PluginMetadata import java.io.File /** @@ -30,6 +31,16 @@ interface PluginRepository { */ suspend fun uninstallPlugin(pluginId: String): Result + /** + * Read plugin metadata from a file without installing it + */ + suspend fun getPluginMetadataFromFile(pluginFile: File): Result + + /** + * Returns true if the incoming file's signing certificate matches the installed plugin's certificate + */ + suspend fun haveMatchingSignatures(incomingFile: File, existingPluginId: String): Result + /** * Install a plugin from file */ 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 f5f0371e31..e2c2d1d024 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt @@ -2,7 +2,9 @@ package com.itsaky.androidide.repositories import android.util.Log import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.plugins.PluginMetadata 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 java.io.File @@ -68,6 +70,22 @@ class PluginRepositoryImpl( } } + override suspend fun getPluginMetadataFromFile(pluginFile: File): Result = withContext(Dispatchers.IO) { + runCatching { + val manager = pluginManager + ?: throw IllegalStateException("Plugin system not available") + manager.getPluginMetadataOnly(pluginFile).getOrThrow().toPluginMetadata() + } + } + + override suspend fun haveMatchingSignatures(incomingFile: File, existingPluginId: String): Result = + withContext(Dispatchers.IO) { + runCatching { + pluginManager?.haveMatchingSignatures(incomingFile, existingPluginId) + ?: throw IllegalStateException("Plugin system not available") + } + } + override suspend fun installPluginFromFile(pluginFile: File): Result = withContext(Dispatchers.IO) { runCatching { val manager = pluginManager 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 da30b1a464..151d631f4c 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 @@ -3,6 +3,7 @@ package com.itsaky.androidide.ui.models import android.net.Uri import androidx.annotation.StringRes import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.plugins.PluginMetadata data class PluginManagerUiState( val isLoading: Boolean = false, @@ -23,6 +24,7 @@ sealed class 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() } @@ -34,6 +36,12 @@ sealed class 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() } sealed class PluginOperation { diff --git a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt index 17112309d4..e21ed73ce9 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodels/PluginManagerViewModel.kt @@ -74,6 +74,11 @@ class PluginManagerViewModel( event.uri, event.deleteSourceAfterInstall ) + is PluginManagerUiEvent.ConfirmOverwrite -> installPlugin( + event.uri, + event.deleteSourceAfterInstall, + checkConflict = false + ) is PluginManagerUiEvent.OpenFilePicker -> openFilePicker() is PluginManagerUiEvent.ShowPluginDetails -> showPluginDetails(event.plugin) @@ -230,10 +235,7 @@ class PluginManagerViewModel( } } - /** - * Install a plugin from URI - */ - private fun installPlugin(uri: Uri, deleteSourceAfterInstall: Boolean) { + private fun installPlugin(uri: Uri, deleteSourceAfterInstall: Boolean, checkConflict: Boolean = true) { viewModelScope.launch { _currentOperation.value = PluginOperation.Installing _uiState.update { it.copy(isInstalling = true) } @@ -258,6 +260,10 @@ class PluginManagerViewModel( tempFile } + if (checkConflict && resolveInstallConflict(tempFile, uri, deleteSourceAfterInstall)) { + return@launch + } + pluginRepository.installPluginFromFile(tempFile) .onSuccess { Log.d(TAG, "Plugin installed successfully") @@ -294,11 +300,46 @@ class PluginManagerViewModel( } } } + _uiState.update { it.copy(isInstalling = false) } + _currentOperation.value = PluginOperation.None } + } + } - _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) { 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 3cadbb43b1..381e679554 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 @@ -26,6 +26,7 @@ import com.itsaky.androidide.plugins.extensions.FileTabMenuItem import com.itsaky.androidide.plugins.extensions.UIExtension import com.itsaky.androidide.plugins.manager.loaders.PluginManifest import com.itsaky.androidide.plugins.manager.loaders.PluginLoader +import com.itsaky.androidide.plugins.manager.loaders.toPluginMetadata import com.itsaky.androidide.plugins.manager.loaders.PluginResourceContext import com.itsaky.androidide.plugins.manager.security.PluginSecurityManager import com.itsaky.androidide.plugins.manager.context.PluginContextImpl @@ -372,12 +373,6 @@ class PluginManager private constructor( } val classLoader = pluginLoader.loadPluginClasses(this::class.java.classLoader!!, nativeLibPath) - if (classLoader == null) { - if (manifest.sidebarItems > 0) { - SidebarSlotManager.releasePluginSlots(manifest.id) - } - return Result.failure(RuntimeException("Failed to create class loader for plugin: ${manifest.id}")) - } logger.debug("Loading main class: ${manifest.mainClass}") val pluginClass = executeWithErrorHandling("load main class ${manifest.mainClass}", manifest.id) { @@ -488,8 +483,9 @@ class PluginManager private constructor( reservedSlotsPluginId?.let { pluginId -> SidebarSlotManager.releasePluginSlots(pluginId) } - logger.error("Failed to load plugin from ${file.name}: ${e.javaClass.simpleName}: ${e.message}", e) - Result.failure(RuntimeException("Error loading plugin: ${e.message}", e)) + logger.error("Failed to load plugin from ${file.name}: ${e.javaClass.simpleName}: ${e.message}", e) + val prefix = if (e is RuntimeException) "" else "[${e.javaClass.simpleName}] " + Result.failure(RuntimeException("Error loading plugin: $prefix${e.message}", e)) } } @@ -554,6 +550,17 @@ class PluginManager private constructor( } + fun haveMatchingSignatures(incomingFile: File, existingPluginId: String): Boolean { + val existingFile = File(pluginsDir, "$existingPluginId.cgp") + val incomingSig = PluginLoader(context, incomingFile).getSignatureHash() + val existingSig = PluginLoader(context, existingFile).getSignatureHash() + if (incomingSig == null || existingSig == null) { + logger.warn("Could not extract signatures for $existingPluginId; treating as mismatch") + return false + } + return incomingSig.contentEquals(existingSig) + } + fun uninstallPlugin(pluginId: String): Boolean { logger.info("=== Starting uninstall for plugin: $pluginId ===") @@ -625,19 +632,7 @@ class PluginManager private constructor( fun getAllPlugins(): List { return loadedPlugins.values.map { loadedPlugin -> PluginInfo( - // Use manifest from AndroidManifest, not the plugin's hardcoded metadata - metadata = PluginMetadata( - id = loadedPlugin.manifest.id, - name = loadedPlugin.manifest.name, - version = loadedPlugin.manifest.version, - description = loadedPlugin.manifest.description, - author = loadedPlugin.manifest.author, - minIdeVersion = loadedPlugin.manifest.minIdeVersion, - dependencies = loadedPlugin.manifest.dependencies, - permissions = loadedPlugin.manifest.permissions, - iconDayPath = loadedPlugin.iconDayPath, - iconNightPath = loadedPlugin.iconNightPath - ), + metadata = loadedPlugin.manifest.toPluginMetadata(), isEnabled = loadedPlugin.isEnabled, isLoaded = true ) diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginLoader.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginLoader.kt index e4c0b92852..f4a3c05a22 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginLoader.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginLoader.kt @@ -89,9 +89,9 @@ class PluginLoader( /** * Load plugin classes from APK */ - fun loadPluginClasses(parentClassLoader: ClassLoader, nativeLibPath: String? = null): DexClassLoader? { + fun loadPluginClasses(parentClassLoader: ClassLoader, nativeLibPath: String? = null): DexClassLoader { if (pluginClassLoader != null) { - return pluginClassLoader + return pluginClassLoader!! } try { @@ -100,18 +100,18 @@ class PluginLoader( optimizedDir.mkdirs() } - pluginClassLoader = DexClassLoader( + val loader = DexClassLoader( pluginApk.absolutePath, optimizedDir.absolutePath, nativeLibPath, parentClassLoader ) + pluginClassLoader = loader Log.i(TAG, "Successfully created DexClassLoader for plugin APK (nativeLibPath=$nativeLibPath)") - return pluginClassLoader + return loader } catch (e: Exception) { - Log.e(TAG, "Failed to create DexClassLoader: ${e.message}", e) - return null + throw RuntimeException("Failed to load plugin classes: [${e.javaClass.simpleName}] ${e.message}", e) } } @@ -300,6 +300,25 @@ class PluginLoader( } } + fun getSignatureHash(): ByteArray? { + return try { + val pm = context.packageManager + @Suppress("DEPRECATION") + val info = pm.getPackageArchiveInfo( + pluginApk.absolutePath, + PackageManager.GET_SIGNING_CERTIFICATES or PackageManager.GET_SIGNATURES + ) + @Suppress("DEPRECATION") + val legacySignatures = info?.signatures + val signatures = info?.signingInfo?.apkContentsSigners?.takeIf { it.isNotEmpty() } + ?: legacySignatures + signatures?.firstOrNull()?.toByteArray() + } catch (e: Exception) { + Log.w(TAG, "Failed to extract signature hash from ${pluginApk.absolutePath}", e) + null + } + } + /** * Clean up resources */ diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt index c1f9b0556c..2c3d0f6d75 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginManifest.kt @@ -1,7 +1,9 @@ package com.itsaky.androidide.plugins.manager.loaders +import android.util.Log import com.google.gson.Gson import com.google.gson.annotations.SerializedName +import com.itsaky.androidide.plugins.PluginMetadata import java.io.File import java.io.InputStreamReader import java.util.jar.JarFile @@ -87,7 +89,19 @@ data class ManifestBuildAction( val timeoutMs: Long = 600_000 ) +fun PluginManifest.toPluginMetadata() = PluginMetadata( + id = id, + name = name, + version = version, + description = description, + author = author, + minIdeVersion = minIdeVersion, + dependencies = dependencies, + permissions = permissions +) + object PluginManifestParser { + private const val TAG = "PluginManifestParser" private val gson = Gson() fun parseFromJar(jarFile: File): PluginManifest? { @@ -102,6 +116,7 @@ object PluginManifestParser { gson.fromJson(reader, PluginManifest::class.java)?.normalize() } } catch (e: Exception) { + Log.w(TAG, "Failed to parse plugin.json: [${e.javaClass.simpleName}] ${e.message}", e) null } } @@ -110,6 +125,7 @@ object PluginManifestParser { return try { gson.fromJson(json, PluginManifest::class.java)?.normalize() } catch (e: Exception) { + Log.w(TAG, "Failed to parse plugin manifest JSON: [${e.javaClass.simpleName}] ${e.message}", e) null } } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index a2d4e0fcc6..4491356f7e 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -1004,6 +1004,8 @@ Building… Installing plugin… Install Plugin + Plugin Already Installed + \'%1$s\' v%2$s is already installed. Replace it with v%3$s? Plugin \'%1$s\' built successfully. Would you like to install it now? Install Later @@ -1012,7 +1014,10 @@ Plugin changes will take effect after restarting the app. Do you want to restart now? Restart now Failed to install plugin: %1$s + \'%1$s\' was installed from a different build variant. Uninstall it before installing this version. + Plugin error Plugin file not found + Invalid or corrupted plugin file. Cannot read plugin metadata. Plugin installed successfully Failed to load plugins: %1$s Plugin enabled