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 @@ -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
Expand Down Expand Up @@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Set status
val statusText = when {
!plugin.isLoaded -> "Not Loaded"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/res/drawable/ic_extension.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/black"
android:fillColor="?attr/colorOnSurface"
android:pathData="M20.5,11L19,9.5l1.5,-1.5L19,6.5 17.5,8 16,6.5 14.5,8 16,9.5 14.5,11 16,12.5 17.5,11 19,12.5 20.5,11zM17.5,10c0,0.8 0.7,1.5 1.5,1.5s1.5,-0.7 1.5,-1.5 -0.7,-1.5 -1.5,-1.5S17.5,9.2 17.5,10zM10.5,7.5C10.5,6.1 9.4,5 8,5S5.5,6.1 5.5,7.5 6.6,10 8,10 10.5,8.9 10.5,7.5zM8,8.5C7.2,8.5 6.5,7.8 6.5,7S7.2,5.5 8,5.5 9.5,6.2 9.5,7 8.8,8.5 8,8.5zM8,12c-1.4,0 -2.5,1.1 -2.5,2.5S6.6,17 8,17s2.5,-1.1 2.5,-2.5S9.4,12 8,12zM8,15.5c-0.8,0 -1.5,-0.7 -1.5,-1.5s0.7,-1.5 1.5,-1.5 1.5,0.7 1.5,1.5S8.8,15.5 8,15.5z"/>
</vector>
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ data class PluginMetadata(
val author: String,
val minIdeVersion: String,
val permissions: List<String> = emptyList(),
val dependencies: List<String> = emptyList()
val dependencies: List<String> = emptyList(),
val iconDayPath: String? = null,
val iconNightPath: String? = null
) : Parcelable

enum class PluginPermission(val key: String, val description: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,19 +224,28 @@ class PluginManager private constructor(
verifyDocumentationForLoadedPlugins()
}

fun getPluginMetadataOnly(pluginFile: File): Result<PluginManifest> {
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<Pair<PluginManifest, PluginLoader>> {
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<PluginManifest> =
loadAndValidate(pluginFile).map { it.first }

fun getPluginValidation(pluginFile: File): Result<PluginValidation> =
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
*/
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Comment thread
Daniel-ADFA marked this conversation as resolved.

// Clean up ART cache files in oat directory
try {
val oatDir = File(pluginsDir, "oat")
Expand Down Expand Up @@ -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
)
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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<String?, String?> {
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
Comment thread
Daniel-ADFA marked this conversation as resolved.
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
}
}
Comment thread
Daniel-ADFA marked this conversation as resolved.

fun getPluginMetadata(): PluginManifest? {
try {
val packageInfo = pluginPackageInfo ?: context.packageManager.getPackageArchiveInfo(
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ data class PluginManifest(
val sidebarItems: Int = 0,

@SerializedName("build_actions")
val buildActions: List<ManifestBuildAction> = emptyList()
val buildActions: List<ManifestBuildAction> = emptyList(),

@SerializedName("icon_day")
val iconDay: String? = null,

@SerializedName("icon_night")
val iconNight: String? = null
)

data class ExtensionInfo(
Expand Down
Loading