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..e9d7b2c652 100644
--- a/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt
+++ b/app/src/main/java/com/itsaky/androidide/adapters/PluginListAdapter.kt
@@ -8,11 +8,14 @@ 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
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.plugins.PluginInfo
+import com.itsaky.androidide.utils.isSystemInDarkMode
+import java.io.File
class PluginListAdapter(
private val onActionClick: (PluginInfo, Action) -> Unit
@@ -54,7 +57,27 @@ class PluginListAdapter(
"v$version"
}
pluginAuthor.text = "by ${plugin.metadata.author}"
-
+
+ val iconPath = if (itemView.context.isSystemInDarkMode()) {
+ plugin.metadata.iconNightPath
+ } else {
+ plugin.metadata.iconDayPath
+ }
+
+ 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 {
+ Glide.with(pluginIcon).clear(pluginIcon)
+ pluginIcon.setImageResource(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..f5f0371e31 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,34 @@ 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) {
+ val missing = listOfNotNull(
+ "icon_day".takeIf {
+ metadata.iconDay == null || !validation.iconDayEntryExists
+ },
+ "icon_night".takeIf {
+ metadata.iconNight == null || !validation.iconNightEntryExists
+ }
+ ).joinToString(" and ") { "\"$it\"" }
+ 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 {
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..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
@@ -224,19 +224,28 @@ 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 = 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
*/
@@ -344,6 +353,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 +431,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 +634,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 +1200,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 +1264,19 @@ class PluginManager private constructor(
}
+data class PluginValidation(
+ val manifest: PluginManifest,
+ val isDebug: Boolean,
+ val iconDayEntryExists: Boolean,
+ val iconNightEntryExists: 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..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
@@ -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,51 @@ 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 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(role: String, entryPath: String?): String? {
+ entryPath ?: return null
+ val entry = zip.getEntry(entryPath) ?: return null
+ 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("icon_day", manifest.iconDay) to extractEntry("icon_night", 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 +252,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 +267,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(