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..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 @@ -15,111 +16,120 @@ import java.lang.ref.WeakReference */ object PluginFragmentHelper { - @JvmStatic - var onPluginInflationError: ((pluginId: String, error: Throwable) -> Unit)? = null - - private val pluginContexts = mutableMapOf() - private val serviceRegistries = mutableMapOf() - private val legacyPlugins = mutableSetOf() - - private data class ActivitySnapshot( - val contextRef: WeakReference, - val themeRef: WeakReference - ) - - private val activitySnapshots = mutableMapOf() - - @JvmStatic - fun getCurrentActivityTheme(pluginId: String): Resources.Theme? = - activitySnapshots[pluginId]?.themeRef?.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) - } - - /** - * 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] - } - - /** - * 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] - } - - /** - * 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 + @JvmStatic + var onPluginInflationError: ((pluginId: String, error: Throwable) -> Unit)? = null + + private val pluginContexts = mutableMapOf() + private val serviceRegistries = mutableMapOf() + private val legacyPlugins = mutableSetOf() + + private data class ActivitySnapshot( + val contextRef: WeakReference, + val themeRef: WeakReference + ) + + private val activitySnapshots = mutableMapOf() + + @JvmStatic + fun getCurrentActivityTheme(pluginId: String): Resources.Theme? = + activitySnapshots[pluginId]?.themeRef?.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) + } + + /** + * 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] + } + + /** + * 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] + } + + /** + * 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 + 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) + } else { + defaultInflater.cloneInContext(pluginContext) + } + 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" + } +}