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(