From b0b2c336103a32c1f26c080b4c0a7d3dc11d3858 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sun, 11 Oct 2026 21:52:52 +0100 Subject: [PATCH 1/2] ADFA-6361: Spotless reformat PluginFragment.kt, no functional change The file used four-space indentation, so touching it for the fix pulls the whole file under the ratchet. Committed standalone. --- .../androidide/plugins/base/PluginFragment.kt | 192 +++++++++--------- 1 file changed, 96 insertions(+), 96 deletions(-) diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/base/PluginFragment.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/base/PluginFragment.kt index 2873504902..77b8d5d06e 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/base/PluginFragment.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/base/PluginFragment.kt @@ -15,111 +15,111 @@ import java.lang.ref.WeakReference */ object PluginFragmentHelper { - @JvmStatic - var onPluginInflationError: ((pluginId: String, error: Throwable) -> Unit)? = null + @JvmStatic + var onPluginInflationError: ((pluginId: String, error: Throwable) -> Unit)? = null - private val pluginContexts = mutableMapOf() - private val serviceRegistries = mutableMapOf() - private val legacyPlugins = mutableSetOf() + private val pluginContexts = mutableMapOf() + private val serviceRegistries = mutableMapOf() + private val legacyPlugins = mutableSetOf() - private data class ActivitySnapshot( - val contextRef: WeakReference, - val themeRef: WeakReference - ) + private data class ActivitySnapshot( + val contextRef: WeakReference, + val themeRef: WeakReference + ) - private val activitySnapshots = mutableMapOf() + private val activitySnapshots = mutableMapOf() - @JvmStatic - fun getCurrentActivityTheme(pluginId: String): Resources.Theme? = - activitySnapshots[pluginId]?.themeRef?.get() + @JvmStatic + fun getCurrentActivityTheme(pluginId: String): Resources.Theme? = + activitySnapshots[pluginId]?.themeRef?.get() - @JvmStatic - fun getCurrentActivityContext(pluginId: String): Context? = - activitySnapshots[pluginId]?.contextRef?.get() + @JvmStatic + fun getCurrentActivityContext(pluginId: String): Context? = + activitySnapshots[pluginId]?.contextRef?.get() - /** - * Register a plugin's resource context. - * Called by the plugin manager when loading APK-based plugins. - */ - @InternalPluginApi - @JvmStatic - @JvmOverloads - fun registerPluginContext(pluginId: String, context: Context, isLegacy: Boolean = false) { - pluginContexts[pluginId] = context - if (isLegacy) legacyPlugins.add(pluginId) else legacyPlugins.remove(pluginId) - } + /** + * Register a plugin's resource context. + * Called by the plugin manager when loading APK-based plugins. + */ + @InternalPluginApi + @JvmStatic + @JvmOverloads + fun registerPluginContext(pluginId: String, context: Context, isLegacy: Boolean = false) { + pluginContexts[pluginId] = context + if (isLegacy) legacyPlugins.add(pluginId) else legacyPlugins.remove(pluginId) + } - /** - * Unregister a plugin's resource context. - * Called when a plugin is unloaded. - */ - @InternalPluginApi - @JvmStatic - fun unregisterPluginContext(pluginId: String) { - pluginContexts.remove(pluginId) - activitySnapshots.remove(pluginId) - legacyPlugins.remove(pluginId) - } + /** + * Unregister a plugin's resource context. + * Called when a plugin is unloaded. + */ + @InternalPluginApi + @JvmStatic + fun unregisterPluginContext(pluginId: String) { + pluginContexts.remove(pluginId) + activitySnapshots.remove(pluginId) + legacyPlugins.remove(pluginId) + } - /** - * Get the resource context for a specific plugin. - */ - @JvmStatic - fun getPluginContext(pluginId: String): Context? { - return pluginContexts[pluginId] - } + /** + * Get the resource context for a specific plugin. + */ + @JvmStatic + fun getPluginContext(pluginId: String): Context? { + return pluginContexts[pluginId] + } - /** - * Register a plugin's service registry. - * Called by the plugin manager when creating plugin context. - */ - @InternalPluginApi - @JvmStatic - fun registerServiceRegistry(pluginId: String, registry: ServiceRegistry) { - serviceRegistries[pluginId] = registry - } + /** + * Register a plugin's service registry. + * Called by the plugin manager when creating plugin context. + */ + @InternalPluginApi + @JvmStatic + fun registerServiceRegistry(pluginId: String, registry: ServiceRegistry) { + serviceRegistries[pluginId] = registry + } - /** - * Get the service registry for a specific plugin. - */ - @JvmStatic - fun getServiceRegistry(pluginId: String): ServiceRegistry? { - return serviceRegistries[pluginId] - } + /** + * Get the service registry for a specific plugin. + */ + @JvmStatic + fun getServiceRegistry(pluginId: String): ServiceRegistry? { + return serviceRegistries[pluginId] + } - /** - * Clear all registered plugin contexts. - */ - @InternalPluginApi - @JvmStatic - fun clearAllContexts() { - pluginContexts.clear() - serviceRegistries.clear() - activitySnapshots.clear() - legacyPlugins.clear() - } + /** + * Clear all registered plugin contexts. + */ + @InternalPluginApi + @JvmStatic + fun clearAllContexts() { + pluginContexts.clear() + serviceRegistries.clear() + activitySnapshots.clear() + legacyPlugins.clear() + } - /** - * Create a LayoutInflater with the plugin's resource context. - * Fragments should call this in their onGetLayoutInflater() method. - * - * @param pluginId The plugin ID - * @param defaultInflater The default inflater from the fragment - * @return An inflater with the plugin's context, or the default if not found - */ - @JvmStatic - fun getPluginInflater(pluginId: String, defaultInflater: LayoutInflater): LayoutInflater { - val pluginContext = getPluginContext(pluginId) ?: return defaultInflater - activitySnapshots[pluginId] = ActivitySnapshot( - contextRef = WeakReference(defaultInflater.context), - themeRef = WeakReference(defaultInflater.context.theme) - ) - val inflater = if (pluginId in legacyPlugins) { - pluginContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as? LayoutInflater - ?: defaultInflater.cloneInContext(pluginContext) - } else { - defaultInflater.cloneInContext(pluginContext) - } - return SafePluginLayoutInflater.wrap(inflater, pluginId) - } -} \ No newline at end of file + /** + * Create a LayoutInflater with the plugin's resource context. + * Fragments should call this in their onGetLayoutInflater() method. + * + * @param pluginId The plugin ID + * @param defaultInflater The default inflater from the fragment + * @return An inflater with the plugin's context, or the default if not found + */ + @JvmStatic + fun getPluginInflater(pluginId: String, defaultInflater: LayoutInflater): LayoutInflater { + val pluginContext = getPluginContext(pluginId) ?: return defaultInflater + activitySnapshots[pluginId] = ActivitySnapshot( + contextRef = WeakReference(defaultInflater.context), + themeRef = WeakReference(defaultInflater.context.theme) + ) + val inflater = if (pluginId in legacyPlugins) { + pluginContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as? LayoutInflater + ?: defaultInflater.cloneInContext(pluginContext) + } else { + defaultInflater.cloneInContext(pluginContext) + } + return SafePluginLayoutInflater.wrap(inflater, pluginId) + } +} From cdf85523d21001c07f34558212fa9f205fb0693d Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sun, 11 Oct 2026 22:06:53 +0100 Subject: [PATCH 2/2] ADFA-6361: Keep the activity snapshot when a plugin passes its own inflater PluginFragmentHelper.getPluginInflater() records the inflater's context as the plugin's current activity, which PluginResourceContext uses to resolve resources and the theme for plugins with a custom package ID. A plugin that passes an inflater already running in its own context (for example LayoutInflater.from(parent.context) in a RecyclerView adapter) recorded the PluginResourceContext itself, so getResources() resolved to itself and recursed until StackOverflowError (GlitchTip issue 7749649, 14 events, com.cogo.ai.assistant). getPluginInflater() now leaves the snapshot alone when the inflater's context is the plugin context or wraps it. It is the only writer of the snapshot, and plugins compile against plugin-api without bundling it, so installed plugins get the fix from the host. PluginResourceContextActivitySnapshotTest covers the plugin context and a ContextWrapper over it; plugin-manager's unit tests gain Robolectric for it. --- .../androidide/plugins/base/PluginFragment.kt | 18 +++-- plugin-manager/build.gradle.kts | 1 + .../plugins/manager/loaders/fakeplugin/R.java | 7 ++ ...uginResourceContextActivitySnapshotTest.kt | 67 +++++++++++++++++++ 4 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 plugin-manager/src/test/java/com/itsaky/androidide/plugins/manager/loaders/fakeplugin/R.java create mode 100644 plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginResourceContextActivitySnapshotTest.kt diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/base/PluginFragment.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/base/PluginFragment.kt index 77b8d5d06e..e7f6406927 100644 --- a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/base/PluginFragment.kt +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/base/PluginFragment.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.plugins.base import android.content.Context +import android.content.ContextWrapper import android.content.res.Resources import android.view.LayoutInflater import com.itsaky.androidide.plugins.ServiceRegistry @@ -110,10 +111,13 @@ object PluginFragmentHelper { @JvmStatic fun getPluginInflater(pluginId: String, defaultInflater: LayoutInflater): LayoutInflater { val pluginContext = getPluginContext(pluginId) ?: return defaultInflater - activitySnapshots[pluginId] = ActivitySnapshot( - contextRef = WeakReference(defaultInflater.context), - themeRef = WeakReference(defaultInflater.context.theme) - ) + val hostContext = defaultInflater.context + if (!hostContext.isOrWraps(pluginContext)) { + activitySnapshots[pluginId] = ActivitySnapshot( + contextRef = WeakReference(hostContext), + themeRef = WeakReference(hostContext.theme) + ) + } val inflater = if (pluginId in legacyPlugins) { pluginContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as? LayoutInflater ?: defaultInflater.cloneInContext(pluginContext) @@ -123,3 +127,9 @@ object PluginFragmentHelper { return SafePluginLayoutInflater.wrap(inflater, pluginId) } } + +private tailrec fun Context.isOrWraps(target: Context): Boolean { + if (this === target) return true + val base = (this as? ContextWrapper)?.baseContext ?: return false + return base.isOrWraps(target) +} diff --git a/plugin-manager/build.gradle.kts b/plugin-manager/build.gradle.kts index e75526fe2d..5e5bf01abb 100644 --- a/plugin-manager/build.gradle.kts +++ b/plugin-manager/build.gradle.kts @@ -39,4 +39,5 @@ dependencies { testImplementation(libs.tests.junit) testImplementation(libs.tests.google.truth) + testImplementation(libs.tests.robolectric) } diff --git a/plugin-manager/src/test/java/com/itsaky/androidide/plugins/manager/loaders/fakeplugin/R.java b/plugin-manager/src/test/java/com/itsaky/androidide/plugins/manager/loaders/fakeplugin/R.java new file mode 100644 index 0000000000..92cba1b2c2 --- /dev/null +++ b/plugin-manager/src/test/java/com/itsaky/androidide/plugins/manager/loaders/fakeplugin/R.java @@ -0,0 +1,7 @@ +package com.itsaky.androidide.plugins.manager.loaders.fakeplugin; + +public final class R { + public static final class drawable { + public static final int icon = 0x57020000; + } +} diff --git a/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginResourceContextActivitySnapshotTest.kt b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginResourceContextActivitySnapshotTest.kt new file mode 100644 index 0000000000..81f88442d5 --- /dev/null +++ b/plugin-manager/src/test/kotlin/com/itsaky/androidide/plugins/manager/loaders/PluginResourceContextActivitySnapshotTest.kt @@ -0,0 +1,67 @@ +package com.itsaky.androidide.plugins.manager.loaders + +import android.content.Context +import android.content.ContextWrapper +import android.content.pm.ApplicationInfo +import android.content.pm.PackageInfo +import android.view.LayoutInflater +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.plugins.base.PluginFragmentHelper +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PluginResourceContextActivitySnapshotTest { + private val app: Context = RuntimeEnvironment.getApplication() + + private val pluginContext = + PluginResourceContext( + app, + PLUGIN_ID, + app.resources, + PackageInfo().apply { + packageName = "com.itsaky.androidide.plugins.manager.loaders.fakeplugin" + applicationInfo = ApplicationInfo() + }, + javaClass.classLoader, + ) + + @Before + fun registerPlugin() { + assertThat(pluginContext.usesCustomPackageId()).isTrue() + PluginFragmentHelper.registerPluginContext(PLUGIN_ID, pluginContext) + PluginFragmentHelper.getPluginInflater(PLUGIN_ID, LayoutInflater.from(app)) + } + + @After + fun unregisterPlugin() { + PluginFragmentHelper.unregisterPluginContext(PLUGIN_ID) + } + + @Test + fun anInflaterInThePluginContextDoesNotReplaceTheActivitySnapshot() { + PluginFragmentHelper.getPluginInflater(PLUGIN_ID, LayoutInflater.from(pluginContext)) + + assertThat(PluginFragmentHelper.getCurrentActivityContext(PLUGIN_ID)).isSameInstanceAs(app) + assertThat(pluginContext.resources).isSameInstanceAs(app.resources) + } + + @Test + fun anInflaterOverAWrapperOfThePluginContextDoesNotReplaceTheActivitySnapshot() { + val wrapped = LayoutInflater.from(pluginContext).cloneInContext(ContextWrapper(pluginContext)) + PluginFragmentHelper.getPluginInflater(PLUGIN_ID, wrapped) + + assertThat(PluginFragmentHelper.getCurrentActivityContext(PLUGIN_ID)).isSameInstanceAs(app) + assertThat(pluginContext.resources).isSameInstanceAs(app.resources) + } + + private companion object { + const val PLUGIN_ID = "com.itsaky.androidide.plugins.manager.loaders.fakeplugin" + } +}