From bbdd4fcd347e7d3c7b966aff704283542cac1b4f Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sat, 18 Apr 2026 22:46:08 +0100 Subject: [PATCH 1/2] ADFA-3694: add day/night icon support for plugins Plugins can now declare plugin.icon_day and plugin.icon_night in their AndroidManifest meta-data, pointing at asset paths inside the APK. On install, PluginLoader extracts both icons into a per-plugin icons directory and exposes them via PluginInfo, so PluginListAdapter renders the correct asset for the current system theme. Debug plugins must declare both icons, validated during install via a new getPluginValidation API. The fallback ic_extension drawable is retinted with colorOnSurface so it adapts to light and dark themes. Also sets an explicit compileSdk on plugin-api so standalone plugin builds configure correctly. --- .../androidide/adapters/PluginListAdapter.kt | 25 +++++++++- .../repositories/PluginRepositoryImpl.kt | 24 ++++++--- app/src/main/res/drawable/ic_extension.xml | 2 +- .../com/itsaky/androidide/plugins/IPlugin.kt | 4 +- .../plugins/manager/core/PluginManager.kt | 49 ++++++++++++++----- .../plugins/manager/loaders/PluginLoader.kt | 43 +++++++++++++++- .../plugins/manager/loaders/PluginManifest.kt | 8 ++- 7 files changed, 130 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt index 9a08c09aec..ed605d7024 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt @@ -1,6 +1,8 @@ package com.itsaky.androidide.adapters +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -13,6 +15,9 @@ import com.itsaky.androidide.databinding.ItemPluginBinding import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.plugins.PluginInfo +import com.itsaky.androidide.utils.isSystemInDarkMode +import java.io.File +import androidx.core.graphics.drawable.toDrawable class PluginListAdapter( private val onActionClick: (PluginInfo, Action) -> Unit @@ -54,7 +59,25 @@ class PluginListAdapter( "v$version" } pluginAuthor.text = "by ${plugin.metadata.author}" - + + val iconPath = if (itemView.context.isSystemInDarkMode()) { + plugin.metadata.iconNightPath + } else { + plugin.metadata.iconDayPath + } + + val bitmap = iconPath?.let { path -> + if (File(path).exists()) BitmapFactory.decodeFile(path) else null + } + if (bitmap != null) { + pluginIcon.setImageDrawable(bitmap.toDrawable(itemView.context.resources)) + pluginIcon.background = null + pluginIcon.imageTintList = null + } else { + pluginIcon.setImageDrawable(null) + pluginIcon.setBackgroundResource(R.drawable.ic_extension) + } + // Set status val statusText = when { !plugin.isLoaded -> "Not Loaded" 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 a1cfddd554..12121f3ff2 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt @@ -73,18 +73,28 @@ class PluginRepositoryImpl( val manager = pluginManager ?: throw IllegalStateException("Plugin system not available") - val metadataResult = manager.getPluginMetadataOnly(pluginFile) - if (metadataResult.isFailure) { - if (pluginFile.exists()) { - pluginFile.delete() - } - throw metadataResult.exceptionOrNull() + val validationResult = manager.getPluginValidation(pluginFile) + if (validationResult.isFailure) { + pluginFile.delete() + throw validationResult.exceptionOrNull() ?: Exception("Failed to read plugin metadata") } - val metadata = metadataResult.getOrNull()!! + val validation = validationResult.getOrNull()!! + val metadata = validation.manifest val pluginId = metadata.id + if (validation.isDebug && (metadata.iconDay == null || metadata.iconNight == null)) { + val missing = listOfNotNull( + "icon_day".takeIf { metadata.iconDay == null }, + "icon_night".takeIf { metadata.iconNight == null } + ).joinToString(" and ") { "\"$it\"" } + pluginFile.delete() + throw IllegalArgumentException( + "[$pluginId] Missing $missing in the manifest. Debug plugins must declare both icon_day and icon_night." + ) + } + try { manager.uninstallPlugin(pluginId) Log.d(TAG, "Uninstalled existing version of plugin: $pluginId") diff --git a/app/src/main/res/drawable/ic_extension.xml b/app/src/main/res/drawable/ic_extension.xml index 63629ab07b..feecb50793 100644 --- a/app/src/main/res/drawable/ic_extension.xml +++ b/app/src/main/res/drawable/ic_extension.xml @@ -5,6 +5,6 @@ android:viewportWidth="24" android:viewportHeight="24"> \ No newline at end of file diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt index c4050b5b3d..3a1670e92a 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/IPlugin.kt @@ -20,7 +20,9 @@ data class PluginMetadata( val author: String, val minIdeVersion: String, val permissions: List = emptyList(), - val dependencies: List = emptyList() + val dependencies: List = emptyList(), + val iconDayPath: String? = null, + val iconNightPath: String? = null ) : Parcelable enum class PluginPermission(val key: String, val description: String) { 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 22f9e9a10f..f89d9fb07d 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 @@ -224,19 +224,21 @@ class PluginManager private constructor( verifyDocumentationForLoadedPlugins() } - fun getPluginMetadataOnly(pluginFile: File): Result { - if (!pluginFile.exists()) { - return Result.failure(IllegalArgumentException("Plugin file does not exist: ${pluginFile.absolutePath}")) - } - if (!pluginFile.canRead()) { - return Result.failure(IllegalArgumentException("Cannot read plugin file: ${pluginFile.absolutePath}")) - } - val pluginLoader = PluginLoader(context, pluginFile) - val manifest = pluginLoader.getPluginMetadata() + private fun loadAndValidate(pluginFile: File): Result> { + if (!pluginFile.exists()) return Result.failure(IllegalArgumentException("Plugin file does not exist: ${pluginFile.absolutePath}")) + if (!pluginFile.canRead()) return Result.failure(IllegalArgumentException("Cannot read plugin file: ${pluginFile.absolutePath}")) + val loader = PluginLoader(context, pluginFile) + val manifest = loader.getPluginMetadata() ?: return Result.failure(IllegalArgumentException("Plugin manifest not found in: ${pluginFile.name}")) - return Result.success(manifest) + return Result.success(manifest to loader) } + fun getPluginMetadataOnly(pluginFile: File): Result = + loadAndValidate(pluginFile).map { it.first } + + fun getPluginValidation(pluginFile: File): Result = + loadAndValidate(pluginFile).map { (manifest, loader) -> PluginValidation(manifest, loader.isDebuggable()) } + /** * Load plugin and return both the plugin instance and its metadata */ @@ -344,6 +346,13 @@ class PluginManager private constructor( null } + val (iconDayPath, iconNightPath) = try { + pluginLoader.extractPluginIcons(manifest.id, manifest) + } catch (e: Exception) { + logger.warn("Failed to extract icons for plugin: ${manifest.id}", e) + null to null + } + if (nativeLibPath != null && !permissions.contains(PluginPermission.NATIVE_CODE)) { File(nativeLibPath).deleteRecursively() if (manifest.sidebarItems > 0) { @@ -415,7 +424,11 @@ class PluginManager private constructor( logger.debug("Registered service registry for plugin: ${manifest.id}") val isEnabled = getPluginState(manifest.id) - val loadedPlugin = LoadedPlugin(plugin, manifest, classLoader, pluginContext, isEnabled) + val loadedPlugin = LoadedPlugin( + plugin, manifest, classLoader, pluginContext, isEnabled, + iconDayPath = iconDayPath, + iconNightPath = iconNightPath + ) loadedPlugins[manifest.id] = loadedPlugin if (isEnabled) { try { @@ -614,7 +627,9 @@ class PluginManager private constructor( author = loadedPlugin.manifest.author, minIdeVersion = loadedPlugin.manifest.minIdeVersion, dependencies = loadedPlugin.manifest.dependencies, - permissions = loadedPlugin.manifest.permissions + permissions = loadedPlugin.manifest.permissions, + iconDayPath = loadedPlugin.iconDayPath, + iconNightPath = loadedPlugin.iconNightPath ), isEnabled = loadedPlugin.isEnabled, isLoaded = true @@ -1178,6 +1193,10 @@ class PluginManager private constructor( if (dir.exists()) dir.deleteRecursively() } + File(context.getDir("plugin_icons", Context.MODE_PRIVATE), pluginId).let { dir -> + if (dir.exists()) dir.deleteRecursively() + } + // Clean up ART cache files in oat directory try { val oatDir = File(pluginsDir, "oat") @@ -1238,10 +1257,14 @@ class PluginManager private constructor( } +data class PluginValidation(val manifest: PluginManifest, val isDebug: Boolean) + data class LoadedPlugin( val plugin: IPlugin, val manifest: PluginManifest, val classLoader: ClassLoader, val context: PluginContext, - var isEnabled: Boolean = true + var isEnabled: Boolean = true, + val iconDayPath: String? = null, + val iconNightPath: String? = null ) \ No newline at end of file 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 4e8749e967..42e2baaf79 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 @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.manager.loaders import android.content.Context +import android.content.pm.ApplicationInfo import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.content.res.AssetManager @@ -178,6 +179,41 @@ class PluginLoader( return pluginNativeDir } + fun isDebuggable(): Boolean { + val packageInfo = pluginPackageInfo + ?: context.packageManager.getPackageArchiveInfo( + pluginApk.absolutePath, + PackageManager.GET_META_DATA + ) ?: return false + val appInfo = packageInfo.applicationInfo ?: return false + return (appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + } + + fun extractPluginIcons(pluginId: String, manifest: PluginManifest): Pair { + if (manifest.iconDay == null && manifest.iconNight == null) return null to null + val iconDir = File(context.getDir("plugin_icons", Context.MODE_PRIVATE), pluginId) + iconDir.mkdirs() + val targetPath = iconDir.toPath().toAbsolutePath().normalize() + return try { + ZipFile(pluginApk).use { zip -> + fun extractEntry(entryPath: String?): String? { + entryPath ?: return null + val entry = zip.getEntry(entryPath) ?: return null + val outPath = targetPath.resolve(entryPath.substringAfterLast('/')).normalize() + if (!outPath.startsWith(targetPath)) return null + zip.getInputStream(entry).use { input -> + outPath.toFile().outputStream().use { output -> input.copyTo(output) } + } + return outPath.toFile().absolutePath + } + extractEntry(manifest.iconDay) to extractEntry(manifest.iconNight) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to extract plugin icons for $pluginId", e) + null to null + } + } + fun getPluginMetadata(): PluginManifest? { try { val packageInfo = pluginPackageInfo ?: context.packageManager.getPackageArchiveInfo( @@ -206,6 +242,9 @@ class PluginLoader( // Parse sidebar items count val sidebarItems = metaData.getInt("plugin.sidebar_items", 0) + val iconDay = metaData.getString("plugin.icon_day") + val iconNight = metaData.getString("plugin.icon_night") + return PluginManifest( id = pluginId, name = pluginName, @@ -218,7 +257,9 @@ class PluginLoader( permissions = permissions, dependencies = dependencies, extensions = emptyList(), - sidebarItems = sidebarItems + sidebarItems = sidebarItems, + iconDay = iconDay, + iconNight = iconNight ) } catch (e: Exception) { Log.e(TAG, "Failed to extract plugin metadata: ${e.message}", e) 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 bb665ee317..c1f9b0556c 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 @@ -44,7 +44,13 @@ data class PluginManifest( val sidebarItems: Int = 0, @SerializedName("build_actions") - val buildActions: List = emptyList() + val buildActions: List = emptyList(), + + @SerializedName("icon_day") + val iconDay: String? = null, + + @SerializedName("icon_night") + val iconNight: String? = null ) data class ExtensionInfo( From 1cb3400e9225d1342dbbf923615b606555449206 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 20 Apr 2026 09:53:52 +0100 Subject: [PATCH 2/2] fixes --- .../androidide/adapters/PluginListAdapter.kt | 24 +++++++++---------- .../repositories/PluginRepositoryImpl.kt | 20 ++++++++++------ .../plugins/manager/core/PluginManager.kt | 16 +++++++++++-- .../plugins/manager/loaders/PluginLoader.kt | 16 ++++++++++--- 4 files changed, 52 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt b/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt index ed605d7024..e9d7b2c652 100644 --- a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt +++ b/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt @@ -1,8 +1,6 @@ package com.itsaky.androidide.adapters -import android.graphics.BitmapFactory -import android.graphics.drawable.BitmapDrawable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -10,6 +8,7 @@ import android.widget.PopupMenu import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide import com.itsaky.androidide.R import com.itsaky.androidide.databinding.ItemPluginBinding import com.itsaky.androidide.idetooltips.TooltipManager @@ -17,7 +16,6 @@ import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.plugins.PluginInfo import com.itsaky.androidide.utils.isSystemInDarkMode import java.io.File -import androidx.core.graphics.drawable.toDrawable class PluginListAdapter( private val onActionClick: (PluginInfo, Action) -> Unit @@ -66,16 +64,18 @@ class PluginListAdapter( plugin.metadata.iconDayPath } - val bitmap = iconPath?.let { path -> - if (File(path).exists()) BitmapFactory.decodeFile(path) else null - } - if (bitmap != null) { - pluginIcon.setImageDrawable(bitmap.toDrawable(itemView.context.resources)) - pluginIcon.background = null - pluginIcon.imageTintList = null + pluginIcon.background = null + pluginIcon.imageTintList = null + val iconFile = iconPath?.let(::File)?.takeIf { it.exists() } + if (iconFile != null) { + Glide.with(pluginIcon) + .load(iconFile) + .placeholder(R.drawable.ic_extension) + .error(R.drawable.ic_extension) + .into(pluginIcon) } else { - pluginIcon.setImageDrawable(null) - pluginIcon.setBackgroundResource(R.drawable.ic_extension) + Glide.with(pluginIcon).clear(pluginIcon) + pluginIcon.setImageResource(R.drawable.ic_extension) } // Set status 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 12121f3ff2..f5f0371e31 100644 --- a/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt +++ b/app/src/main/java/com/itsaky/androidide/repositories/PluginRepositoryImpl.kt @@ -84,15 +84,21 @@ class PluginRepositoryImpl( val metadata = validation.manifest val pluginId = metadata.id - if (validation.isDebug && (metadata.iconDay == null || metadata.iconNight == null)) { + if (validation.isDebug) { val missing = listOfNotNull( - "icon_day".takeIf { metadata.iconDay == null }, - "icon_night".takeIf { metadata.iconNight == null } + "icon_day".takeIf { + metadata.iconDay == null || !validation.iconDayEntryExists + }, + "icon_night".takeIf { + metadata.iconNight == null || !validation.iconNightEntryExists + } ).joinToString(" and ") { "\"$it\"" } - pluginFile.delete() - throw IllegalArgumentException( - "[$pluginId] Missing $missing in the manifest. Debug plugins must declare both icon_day and icon_night." - ) + if (missing.isNotEmpty()) { + pluginFile.delete() + throw IllegalArgumentException( + "[$pluginId] Missing $missing for debug plugin. Debug plugins must declare and ship both icon_day and icon_night assets." + ) + } } try { 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 f89d9fb07d..3cadbb43b1 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 @@ -237,7 +237,14 @@ class PluginManager private constructor( loadAndValidate(pluginFile).map { it.first } fun getPluginValidation(pluginFile: File): Result = - loadAndValidate(pluginFile).map { (manifest, loader) -> PluginValidation(manifest, loader.isDebuggable()) } + loadAndValidate(pluginFile).map { (manifest, loader) -> + PluginValidation( + manifest = manifest, + isDebug = loader.isDebuggable(), + iconDayEntryExists = manifest.iconDay?.let(loader::hasEntry) ?: false, + iconNightEntryExists = manifest.iconNight?.let(loader::hasEntry) ?: false + ) + } /** * Load plugin and return both the plugin instance and its metadata @@ -1257,7 +1264,12 @@ class PluginManager private constructor( } -data class PluginValidation(val manifest: PluginManifest, val isDebug: Boolean) +data class PluginValidation( + val manifest: PluginManifest, + val isDebug: Boolean, + val iconDayEntryExists: Boolean, + val iconNightEntryExists: Boolean +) data class LoadedPlugin( val plugin: IPlugin, 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 42e2baaf79..e4c0b92852 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 @@ -189,24 +189,34 @@ class PluginLoader( return (appInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 } + fun hasEntry(entryPath: String): Boolean = + try { + ZipFile(pluginApk).use { it.getEntry(entryPath) != null } + } catch (e: Exception) { + Log.w(TAG, "Failed to inspect plugin APK for entry '$entryPath'", e) + false + } + fun extractPluginIcons(pluginId: String, manifest: PluginManifest): Pair { if (manifest.iconDay == null && manifest.iconNight == null) return null to null val iconDir = File(context.getDir("plugin_icons", Context.MODE_PRIVATE), pluginId) + iconDir.deleteRecursively() iconDir.mkdirs() val targetPath = iconDir.toPath().toAbsolutePath().normalize() return try { ZipFile(pluginApk).use { zip -> - fun extractEntry(entryPath: String?): String? { + fun extractEntry(role: String, entryPath: String?): String? { entryPath ?: return null val entry = zip.getEntry(entryPath) ?: return null - val outPath = targetPath.resolve(entryPath.substringAfterLast('/')).normalize() + val ext = entryPath.substringAfterLast('.', "").ifEmpty { "png" } + val outPath = targetPath.resolve("$role.$ext").normalize() if (!outPath.startsWith(targetPath)) return null zip.getInputStream(entry).use { input -> outPath.toFile().outputStream().use { output -> input.copyTo(output) } } return outPath.toFile().absolutePath } - extractEntry(manifest.iconDay) to extractEntry(manifest.iconNight) + extractEntry("icon_day", manifest.iconDay) to extractEntry("icon_night", manifest.iconNight) } } catch (e: Exception) { Log.w(TAG, "Failed to extract plugin icons for $pluginId", e)