Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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))
Expand All @@ -249,6 +262,9 @@ class PluginManagerActivity : EdgeToEdgeIDEActivity() {
is PluginManagerUiEffect.ShowRestartPrompt -> {
showRestartPrompt(this, cancelable = false)
}
is PluginManagerUiEffect.ShowOverwriteConfirmation -> {
showOverwriteConfirmation(effect)
}
}
}

Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -30,6 +31,16 @@ interface PluginRepository {
*/
suspend fun uninstallPlugin(pluginId: String): Result<Boolean>

/**
* Read plugin metadata from a file without installing it
*/
suspend fun getPluginMetadataFromFile(pluginFile: File): Result<PluginMetadata>

/**
* Returns true if the incoming file's signing certificate matches the installed plugin's certificate
*/
suspend fun haveMatchingSignatures(incomingFile: File, existingPluginId: String): Result<Boolean>

/**
* Install a plugin from file
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -68,6 +70,22 @@ class PluginRepositoryImpl(
}
}

override suspend fun getPluginMetadataFromFile(pluginFile: File): Result<PluginMetadata> = 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<Boolean> =
withContext(Dispatchers.IO) {
runCatching {
pluginManager?.haveMatchingSignatures(incomingFile, existingPluginId)
?: throw IllegalStateException("Plugin system not available")
}
}

override suspend fun installPluginFromFile(pluginFile: File): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val manager = pluginManager
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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()
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) }
Expand All @@ -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")
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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))
}
}

Expand Down Expand Up @@ -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()
Comment thread
Daniel-ADFA marked this conversation as resolved.
if (incomingSig == null || existingSig == null) {
logger.warn("Could not extract signatures for $existingPluginId; treating as mismatch")
return false
}
return incomingSig.contentEquals(existingSig)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fun uninstallPlugin(pluginId: String): Boolean {
logger.info("=== Starting uninstall for plugin: $pluginId ===")

Expand Down Expand Up @@ -625,19 +632,7 @@ class PluginManager private constructor(
fun getAllPlugins(): List<PluginInfo> {
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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
}
Comment thread
Daniel-ADFA marked this conversation as resolved.
}

Expand Down Expand Up @@ -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
}
}
Comment thread
Daniel-ADFA marked this conversation as resolved.

/**
* Clean up resources
*/
Expand Down
Loading
Loading