Use codegen to catch LinkageErrors from plugins

Plugins that were built against old apis have the potential to crash
SystemUI when unsupported calls are made into them, or they make calls
to methods that no longer exist. These cases cause a LinkageError to be
thrown at the callsite. Incrememting the plugin interface version can
mitigate some of these issues, but is an incomplete solution and
requires manually maintence.

This cl attempts to solve this problem by using codegen to write a proxy
implementation of the interface. This proxy catches those errors, logs
them, and provides a recovery path where the plugin can be disabled but
the application can continue. Recovery will not be possible at every
callsite, but this will at least provide standardized wtf logging for
all locations and a straightforward mechanism for correcting issues
when they occur.

I've also changed VersionCheckerImpl to log a wtf and disable the plugin
in the event that versions don't match. This is so that SystemUI can
continue as normal in the event that plugin interface has changed.

Bug: 359432141
Test: Manually checked mismatched clock apk
Flag: NONE Not user-facing, adding catch blocks
Change-Id: Iee7e1dd0faba5c0c4b9fe71f3678d077a2a8b939
diff --git a/packages/SystemUI/plugin/Android.bp b/packages/SystemUI/plugin/Android.bp
index 682a68f..a26cf12 100644
--- a/packages/SystemUI/plugin/Android.bp
+++ b/packages/SystemUI/plugin/Android.bp
@@ -23,9 +23,7 @@
 }
 
 java_library {
-
     name: "SystemUIPluginLib",
-
     srcs: [
         "bcsmartspace/src/**/*.java",
         "bcsmartspace/src/**/*.kt",
@@ -40,6 +38,8 @@
         export_proguard_flags_files: true,
     },
 
+    plugins: ["PluginAnnotationProcessor"],
+
     // If you add a static lib here, you may need to also add the package to the ClassLoaderFilter
     // in PluginInstance. That will ensure that loaded plugins have access to the related classes.
     // You should also add it to proguard_common.flags so that proguard does not remove the portions
@@ -53,7 +53,6 @@
         "SystemUILogLib",
         "androidx.annotation_annotation",
     ],
-
 }
 
 android_app {
diff --git a/packages/SystemUI/plugin/src/com/android/systemui/plugins/TestPlugin.kt b/packages/SystemUI/plugin/src/com/android/systemui/plugins/TestPlugin.kt
new file mode 100644
index 0000000..33f7b7a
--- /dev/null
+++ b/packages/SystemUI/plugin/src/com/android/systemui/plugins/TestPlugin.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package com.android.systemui.plugins
+
+import com.android.systemui.plugins.annotations.ProtectedInterface
+import com.android.systemui.plugins.annotations.ProtectedReturn
+import com.android.systemui.plugins.annotations.ProvidesInterface
+
+@ProtectedInterface
+@ProvidesInterface(action = TestPlugin.ACTION, version = TestPlugin.VERSION)
+/** Interface intended for use in tests */
+interface TestPlugin : Plugin {
+    companion object {
+        const val VERSION = 1
+
+        const val ACTION = "testAction"
+    }
+
+    @ProtectedReturn("return new Object();")
+    /** Test method, implemented by test */
+    fun methodThrowsError(): Object
+}
diff --git a/packages/SystemUI/plugin/src/com/android/systemui/plugins/clocks/ClockProviderPlugin.kt b/packages/SystemUI/plugin/src/com/android/systemui/plugins/clocks/ClockProviderPlugin.kt
index 8dc4815..6d27b6f 100644
--- a/packages/SystemUI/plugin/src/com/android/systemui/plugins/clocks/ClockProviderPlugin.kt
+++ b/packages/SystemUI/plugin/src/com/android/systemui/plugins/clocks/ClockProviderPlugin.kt
@@ -21,7 +21,11 @@
 import com.android.internal.annotations.Keep
 import com.android.systemui.log.core.MessageBuffer
 import com.android.systemui.plugins.Plugin
+import com.android.systemui.plugins.annotations.GeneratedImport
+import com.android.systemui.plugins.annotations.ProtectedInterface
+import com.android.systemui.plugins.annotations.ProtectedReturn
 import com.android.systemui.plugins.annotations.ProvidesInterface
+import com.android.systemui.plugins.annotations.SimpleProperty
 import java.io.PrintWriter
 import java.util.Locale
 import java.util.TimeZone
@@ -31,6 +35,7 @@
 typealias ClockId = String
 
 /** A Plugin which exposes the ClockProvider interface */
+@ProtectedInterface
 @ProvidesInterface(action = ClockProviderPlugin.ACTION, version = ClockProviderPlugin.VERSION)
 interface ClockProviderPlugin : Plugin, ClockProvider {
     companion object {
@@ -40,31 +45,42 @@
 }
 
 /** Interface for building clocks and providing information about those clocks */
+@ProtectedInterface
+@GeneratedImport("java.util.List")
+@GeneratedImport("java.util.ArrayList")
 interface ClockProvider {
     /** Initializes the clock provider with debug log buffers */
     fun initialize(buffers: ClockMessageBuffers?)
 
+    @ProtectedReturn("return new ArrayList<ClockMetadata>();")
     /** Returns metadata for all clocks this provider knows about */
     fun getClocks(): List<ClockMetadata>
 
+    @ProtectedReturn("return null;")
     /** Initializes and returns the target clock design */
-    fun createClock(settings: ClockSettings): ClockController
+    fun createClock(settings: ClockSettings): ClockController?
 
+    @ProtectedReturn("return new ClockPickerConfig(\"\", \"\", \"\", null);")
     /** Settings configuration parameters for the clock */
     fun getClockPickerConfig(id: ClockId): ClockPickerConfig
 }
 
 /** Interface for controlling an active clock */
+@ProtectedInterface
 interface ClockController {
+    @get:SimpleProperty
     /** A small version of the clock, appropriate for smaller viewports */
     val smallClock: ClockFaceController
 
+    @get:SimpleProperty
     /** A large version of the clock, appropriate when a bigger viewport is available */
     val largeClock: ClockFaceController
 
+    @get:SimpleProperty
     /** Determines the way the hosting app should behave when rendering either clock face */
     val config: ClockConfig
 
+    @get:SimpleProperty
     /** Events that clocks may need to respond to */
     val events: ClockEvents
 
@@ -76,19 +92,26 @@
 }
 
 /** Interface for a specific clock face version rendered by the clock */
+@ProtectedInterface
 interface ClockFaceController {
+    @get:SimpleProperty
+    @Deprecated("Prefer use of layout")
     /** View that renders the clock face */
     val view: View
 
+    @get:SimpleProperty
     /** Layout specification for this clock */
     val layout: ClockFaceLayout
 
+    @get:SimpleProperty
     /** Determines the way the hosting app should behave when rendering this clock face */
     val config: ClockFaceConfig
 
+    @get:SimpleProperty
     /** Events specific to this clock face */
     val events: ClockFaceEvents
 
+    @get:SimpleProperty
     /** Triggers for various animations */
     val animations: ClockAnimations
 }
@@ -107,14 +130,21 @@
 
 data class AodClockBurnInModel(val scale: Float, val translationX: Float, val translationY: Float)
 
-/** Specifies layout information for the */
+/** Specifies layout information for the clock face */
+@ProtectedInterface
+@GeneratedImport("java.util.ArrayList")
+@GeneratedImport("android.view.View")
 interface ClockFaceLayout {
+    @get:ProtectedReturn("return new ArrayList<View>();")
     /** All clock views to add to the root constraint layout before applying constraints. */
     val views: List<View>
 
+    @ProtectedReturn("return constraints;")
     /** Custom constraints to apply to Lockscreen ConstraintLayout. */
     fun applyConstraints(constraints: ConstraintSet): ConstraintSet
 
+    @ProtectedReturn("return constraints;")
+    /** Custom constraints to apply to preview ConstraintLayout. */
     fun applyPreviewConstraints(constraints: ConstraintSet): ConstraintSet
 
     fun applyAodBurnIn(aodBurnInModel: AodClockBurnInModel)
@@ -145,7 +175,9 @@
 }
 
 /** Events that should call when various rendering parameters change */
+@ProtectedInterface
 interface ClockEvents {
+    @get:ProtectedReturn("return false;")
     /** Set to enable or disable swipe interaction */
     var isReactiveTouchInteractionEnabled: Boolean
 
@@ -187,6 +219,7 @@
 )
 
 /** Methods which trigger various clock animations */
+@ProtectedInterface
 interface ClockAnimations {
     /** Runs an enter animation (if any) */
     fun enter()
@@ -230,6 +263,7 @@
 }
 
 /** Events that have specific data about the related face */
+@ProtectedInterface
 interface ClockFaceEvents {
     /** Call every time tick */
     fun onTimeTick()
@@ -270,7 +304,9 @@
 /** Some data about a clock design */
 data class ClockMetadata(val clockId: ClockId)
 
-data class ClockPickerConfig(
+data class ClockPickerConfig
+@JvmOverloads
+constructor(
     val id: String,
 
     /** Localized name of the clock */
@@ -338,7 +374,7 @@
     /** Transition to AOD should move smartspace like large clock instead of small clock */
     val useAlternateSmartspaceAODTransition: Boolean = false,
 
-    /** Use ClockPickerConfig.isReactiveToTone instead */
+    /** Deprecated version of isReactiveToTone; moved to ClockPickerConfig */
     @Deprecated("TODO(b/352049256): Remove in favor of ClockPickerConfig.isReactiveToTone")
     val isReactiveToTone: Boolean = true,
 
diff --git a/packages/SystemUI/plugin_core/Android.bp b/packages/SystemUI/plugin_core/Android.bp
index 521c019..31fbda5 100644
--- a/packages/SystemUI/plugin_core/Android.bp
+++ b/packages/SystemUI/plugin_core/Android.bp
@@ -24,8 +24,13 @@
 
 java_library {
     sdk_version: "current",
-    name: "PluginCoreLib",
-    srcs: ["src/**/*.java"],
+    name: "PluginAnnotationLib",
+    host_supported: true,
+    device_supported: true,
+    srcs: [
+        "src/**/annotations/*.java",
+        "src/**/annotations/*.kt",
+    ],
     optimize: {
         proguard_flags_files: ["proguard.flags"],
         // Ensure downstream clients that reference this as a shared lib
@@ -37,3 +42,59 @@
     // no compatibility issues with launcher
     java_version: "1.8",
 }
+
+java_library {
+    sdk_version: "current",
+    name: "PluginCoreLib",
+    device_supported: true,
+    srcs: [
+        "src/**/*.java",
+        "src/**/*.kt",
+    ],
+    exclude_srcs: [
+        "src/**/annotations/*.java",
+        "src/**/annotations/*.kt",
+        "src/**/processor/*.java",
+        "src/**/processor/*.kt",
+    ],
+    static_libs: [
+        "PluginAnnotationLib",
+    ],
+    optimize: {
+        proguard_flags_files: ["proguard.flags"],
+        // Ensure downstream clients that reference this as a shared lib
+        // inherit the appropriate flags to preserve annotations.
+        export_proguard_flags_files: true,
+    },
+
+    // Enforce that the library is built against java 8 so that there are
+    // no compatibility issues with launcher
+    java_version: "1.8",
+}
+
+java_library {
+    java_version: "1.8",
+    name: "PluginAnnotationProcessorLib",
+    host_supported: true,
+    device_supported: false,
+    srcs: [
+        "src/**/processor/*.java",
+        "src/**/processor/*.kt",
+    ],
+    plugins: ["auto_service_plugin"],
+    static_libs: [
+        "androidx.annotation_annotation",
+        "auto_service_annotations",
+        "auto_common",
+        "PluginAnnotationLib",
+        "guava",
+        "jsr330",
+    ],
+}
+
+java_plugin {
+    name: "PluginAnnotationProcessor",
+    processor_class: "com.android.systemui.plugins.processor.ProtectedPluginProcessor",
+    static_libs: ["PluginAnnotationProcessorLib"],
+    java_version: "1.8",
+}
diff --git a/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/Plugin.java b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/Plugin.java
index 8ff6c11..84040f9 100644
--- a/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/Plugin.java
+++ b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/Plugin.java
@@ -15,6 +15,7 @@
 
 import android.content.Context;
 
+import com.android.systemui.plugins.annotations.ProtectedReturn;
 import com.android.systemui.plugins.annotations.Requires;
 
 /**
@@ -116,6 +117,8 @@
      * @deprecated
      * @see Requires
      */
+    @Deprecated
+    @ProtectedReturn(statement = "return -1;")
     default int getVersion() {
         // Default of -1 indicates the plugin supports the new Requires model.
         return -1;
diff --git a/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/PluginWrapper.kt b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/PluginWrapper.kt
new file mode 100644
index 0000000..debb318
--- /dev/null
+++ b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/PluginWrapper.kt
@@ -0,0 +1,20 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package com.android.systemui.plugins
+
+/** [PluginWrapper] wraps an interface used by a plugin */
+interface PluginWrapper<T> {
+    /** Instance that is being wrapped */
+    fun getPlugin(): T
+}
diff --git a/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/ProtectedPluginListener.kt b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/ProtectedPluginListener.kt
new file mode 100644
index 0000000..3a1f251
--- /dev/null
+++ b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/ProtectedPluginListener.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package com.android.systemui.plugins
+
+/** Listener for events from proxy types generated by [ProtectedPluginProcessor]. */
+interface ProtectedPluginListener {
+    /**
+     * Called when a method call produces a [LinkageError] before returning. This callback is
+     * provided so that the host application can terminate the plugin or log the error as
+     * appropriate.
+     *
+     * @return true to terminate all methods within this object; false if the error is recoverable
+     *   and the proxied plugin should continue to operate as normal.
+     */
+    fun onFail(className: String, methodName: String, failure: LinkageError): Boolean
+}
diff --git a/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/annotations/ProtectedInterface.kt b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/annotations/ProtectedInterface.kt
new file mode 100644
index 0000000..12a977d
--- /dev/null
+++ b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/annotations/ProtectedInterface.kt
@@ -0,0 +1,67 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package com.android.systemui.plugins.annotations
+
+/**
+ * This annotation marks denotes that an interface should use a proxy layer to protect the plugin
+ * host from crashing due to [LinkageError]s originating within the plugin's implementation.
+ */
+@Target(AnnotationTarget.CLASS)
+@Retention(AnnotationRetention.BINARY)
+annotation class ProtectedInterface
+
+/**
+ * This annotation specifies any additional imports that the processor will require when generating
+ * the proxy implementation for the target interface. The interface in question must still be
+ * annotated with [ProtectedInterface].
+ */
+@Repeatable
+@Target(AnnotationTarget.CLASS)
+@Retention(AnnotationRetention.BINARY)
+annotation class GeneratedImport(val extraImport: String)
+
+/**
+ * This annotation provides default values to return when the proxy implementation catches a
+ * [LinkageError]. The string specified should be a simple but valid java statement. In most cases
+ * it should be a return statement of the appropriate type, but in some cases throwing a known
+ * exception type may be preferred.
+ *
+ * This annotation is not required for methods that return void, but will behave the same way.
+ */
+@Target(
+    AnnotationTarget.FUNCTION,
+    AnnotationTarget.PROPERTY,
+    AnnotationTarget.PROPERTY_GETTER,
+    AnnotationTarget.PROPERTY_SETTER,
+)
+@Retention(AnnotationRetention.BINARY)
+annotation class ProtectedReturn(val statement: String)
+
+/**
+ * Some very simple properties and methods need not be protected by the proxy implementation. This
+ * annotation can be used to omit the normal try-catch wrapper the proxy is using. These members
+ * will instead be a direct passthrough.
+ *
+ * It should only be used for members where the plugin implementation is expected to be exceedingly
+ * simple. Any member marked with this annotation should be no more complex than kotlin's automatic
+ * properties, and make no other method calls whatsoever.
+ */
+@Target(
+    AnnotationTarget.FUNCTION,
+    AnnotationTarget.PROPERTY,
+    AnnotationTarget.PROPERTY_GETTER,
+    AnnotationTarget.PROPERTY_SETTER,
+)
+@Retention(AnnotationRetention.BINARY)
+annotation class SimpleProperty
diff --git a/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/processor/ProtectedPluginProcessor.kt b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/processor/ProtectedPluginProcessor.kt
new file mode 100644
index 0000000..6b54d89
--- /dev/null
+++ b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/processor/ProtectedPluginProcessor.kt
@@ -0,0 +1,356 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package com.android.systemui.plugins.processor
+
+import com.android.systemui.plugins.annotations.GeneratedImport
+import com.android.systemui.plugins.annotations.ProtectedInterface
+import com.android.systemui.plugins.annotations.ProtectedReturn
+import com.android.systemui.plugins.annotations.SimpleProperty
+import com.google.auto.service.AutoService
+import javax.annotation.processing.AbstractProcessor
+import javax.annotation.processing.ProcessingEnvironment
+import javax.annotation.processing.RoundEnvironment
+import javax.lang.model.element.Element
+import javax.lang.model.element.ElementKind
+import javax.lang.model.element.ExecutableElement
+import javax.lang.model.element.PackageElement
+import javax.lang.model.element.TypeElement
+import javax.lang.model.type.TypeKind
+import javax.lang.model.type.TypeMirror
+import javax.tools.Diagnostic.Kind
+import kotlin.collections.ArrayDeque
+
+/**
+ * [ProtectedPluginProcessor] generates a proxy implementation for interfaces annotated with
+ * [ProtectedInterface] which catches [LinkageError]s generated by the proxied target. This protects
+ * the plugin host from crashing due to out-of-date plugin code, where some call has changed so that
+ * the [ClassLoader] can no longer resolve it correctly.
+ *
+ * [PluginInstance] observes these failures via [ProtectedMethodListener] and unloads the plugin in
+ * question to prevent further issues. This persists through further load/unload requests.
+ *
+ * To centralize access to the proxy types, an additional type [PluginProtector] is also generated.
+ * This class provides static methods which wrap an instance of the target interface in the proxy
+ * type if it is not already an instance of the proxy.
+ */
+@AutoService(ProtectedPluginProcessor::class)
+class ProtectedPluginProcessor : AbstractProcessor() {
+    private lateinit var procEnv: ProcessingEnvironment
+
+    override fun init(procEnv: ProcessingEnvironment) {
+        this.procEnv = procEnv
+    }
+
+    override fun getSupportedAnnotationTypes(): Set<String> =
+        setOf("com.android.systemui.plugins.annotations.ProtectedInterface")
+
+    private data class TargetData(
+        val attribute: TypeElement,
+        val sourceType: Element,
+        val sourcePkg: String,
+        val sourceName: String,
+        val outputName: String,
+    )
+
+    override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
+        val targets = mutableMapOf<String, TargetData>() // keyed by fully-qualified source name
+        val additionalImports = mutableSetOf<String>()
+        for (attr in annotations) {
+            for (target in roundEnv.getElementsAnnotatedWith(attr)) {
+                val sourceName = "${target.simpleName}"
+                val outputName = "${sourceName}Protector"
+                val pkg = (target.getEnclosingElement() as PackageElement).qualifiedName.toString()
+                targets.put("$target", TargetData(attr, target, pkg, sourceName, outputName))
+
+                // This creates excessive imports, but it should be fine
+                additionalImports.add("$pkg.$sourceName")
+                additionalImports.add("$pkg.$outputName")
+            }
+        }
+
+        if (targets.size <= 0) return false
+        for ((_, sourceType, sourcePkg, sourceName, outputName) in targets.values) {
+            // Find all methods in this type and all super types to that need to be implemented
+            val types = ArrayDeque<TypeMirror>().apply { addLast(sourceType.asType()) }
+            val impAttrs = mutableListOf<GeneratedImport>()
+            val methods = mutableListOf<ExecutableElement>()
+            while (types.size > 0) {
+                val typeMirror = types.removeLast()
+                if (typeMirror.toString() == "java.lang.Object") continue
+                val type = procEnv.typeUtils.asElement(typeMirror)
+                for (member in type.enclosedElements) {
+                    if (member.kind != ElementKind.METHOD) continue
+                    methods.add(member as ExecutableElement)
+                }
+
+                impAttrs.addAll(type.getAnnotationsByType(GeneratedImport::class.java))
+                types.addAll(procEnv.typeUtils.directSupertypes(typeMirror))
+            }
+
+            val file = procEnv.filer.createSourceFile("$outputName")
+            TabbedWriter.writeTo(file.openWriter()) {
+                line("package $sourcePkg;")
+                line()
+
+                // Imports used by the proxy implementation
+                line("import android.util.Log;")
+                line("import java.lang.LinkageError;")
+                line("import com.android.systemui.plugins.PluginWrapper;")
+                line("import com.android.systemui.plugins.ProtectedPluginListener;")
+                line()
+
+                // Imports of other generated types
+                if (additionalImports.size > 0) {
+                    for (impTarget in additionalImports) {
+                        line("import $impTarget;")
+                    }
+                    line()
+                }
+
+                // Imports declared via @GeneratedImport
+                if (impAttrs.size > 0) {
+                    for (impAttr in impAttrs) {
+                        line("import ${impAttr.extraImport};")
+                    }
+                    line()
+                }
+
+                val interfaces = "$sourceName, PluginWrapper<$sourceName>"
+                braceBlock("public class $outputName implements $interfaces") {
+                    line("private static final String CLASS = \"$sourceName\";")
+
+                    // Static factory method to prevent wrapping the same object twice
+                    parenBlock("public static $outputName protect") {
+                        line("$sourceName instance,")
+                        line("ProtectedPluginListener listener")
+                    }
+                    braceBlock {
+                        line("if (instance instanceof $outputName)")
+                        line("    return ($outputName)instance;")
+                        line("return new $outputName(instance, listener);")
+                    }
+                    line()
+
+                    // Member Fields
+                    line("private $sourceName mInstance;")
+                    line("private ProtectedPluginListener mListener;")
+                    line("private boolean mHasError = false;")
+                    line()
+
+                    // Constructor
+                    parenBlock("private $outputName") {
+                        line("$sourceName instance,")
+                        line("ProtectedPluginListener listener")
+                    }
+                    braceBlock {
+                        line("mInstance = instance;")
+                        line("mListener = listener;")
+                    }
+                    line()
+
+                    // Wrapped instance getter for version checker
+                    braceBlock("public $sourceName getPlugin()") { line("return mInstance;") }
+
+                    // Method implementations
+                    for (method in methods) {
+                        val methodName = method.simpleName
+                        val returnTypeName = method.returnType.toString()
+                        val callArgs = StringBuilder()
+                        var isFirst = true
+
+                        line("@Override")
+                        parenBlock("public $returnTypeName $methodName") {
+                            // While copying the method signature for the proxy type, we also
+                            // accumulate arguments for the nested callsite.
+                            for (param in method.parameters) {
+                                if (!isFirst) completeLine(",")
+                                startLine("${param.asType()} ${param.simpleName}")
+                                isFirst = false
+
+                                if (callArgs.length > 0) callArgs.append(", ")
+                                callArgs.append(param.simpleName)
+                            }
+                        }
+
+                        val isVoid = method.returnType.kind == TypeKind.VOID
+                        val nestedCall = "mInstance.$methodName($callArgs)"
+                        val callStatement =
+                            when {
+                                isVoid -> "$nestedCall;"
+                                targets.containsKey(returnTypeName) -> {
+                                    val targetType = targets.get(returnTypeName)!!.outputName
+                                    "return $targetType.protect($nestedCall, mListener);"
+                                }
+                                else -> "return $nestedCall;"
+                            }
+
+                        // Simple property methods forgo protection
+                        val simpleAttr = method.getAnnotation(SimpleProperty::class.java)
+                        if (simpleAttr != null) {
+                            braceBlock {
+                                line("final String METHOD = \"$methodName\";")
+                                line(callStatement)
+                            }
+                            line()
+                            continue
+                        }
+
+                        // Standard implementation wraps nested call in try-catch
+                        braceBlock {
+                            val retAttr = method.getAnnotation(ProtectedReturn::class.java)
+                            val errorStatement =
+                                when {
+                                    retAttr != null -> retAttr.statement
+                                    isVoid -> "return;"
+                                    else -> {
+                                        // Non-void methods must be annotated.
+                                        procEnv.messager.printMessage(
+                                            Kind.ERROR,
+                                            "$outputName.$methodName must be annotated with " +
+                                                "@ProtectedReturn or @SimpleProperty",
+                                        )
+                                        "throw ex;"
+                                    }
+                                }
+
+                            line("final String METHOD = \"$methodName\";")
+
+                            // Return immediately if any previous call has failed.
+                            braceBlock("if (mHasError)") { line(errorStatement) }
+
+                            // Protect callsite in try/catch block
+                            braceBlock("try") { line(callStatement) }
+
+                            // Notify listener when a LinkageError is caught
+                            braceBlock("catch (LinkageError ex)") {
+                                line("Log.wtf(CLASS, \"Failed to execute: \" + METHOD, ex);")
+                                line("mHasError = mListener.onFail(CLASS, METHOD, ex);")
+                                line(errorStatement)
+                            }
+                        }
+                        line()
+                    }
+                }
+            }
+        }
+
+        // Write a centralized static factory type to its own file. This is for convience so that
+        // PluginInstance need not resolve each generated type at runtime as plugins are loaded.
+        val factoryFile = procEnv.filer.createSourceFile("PluginProtector")
+        TabbedWriter.writeTo(factoryFile.openWriter()) {
+            line("package com.android.systemui.plugins;")
+            line()
+
+            line("import java.util.Map;")
+            line("import java.util.ArrayList;")
+            line("import java.util.HashSet;")
+            line("import android.util.Log;")
+            line("import static java.util.Map.entry;")
+            line()
+
+            for (impTarget in additionalImports) {
+                line("import $impTarget;")
+            }
+            line()
+
+            braceBlock("public final class PluginProtector") {
+                line("private PluginProtector() { }")
+                line()
+
+                line("private static final String TAG = \"PluginProtector\";")
+                line()
+
+                // Untyped factory SAM, private to this type.
+                braceBlock("private interface Factory") {
+                    line("Object create(Object plugin, ProtectedPluginListener listener);")
+                }
+                line()
+
+                // Store a reference to each `protect` method in a map by interface type.
+                parenBlock("private static final Map<Class, Factory> sFactories = Map.ofEntries") {
+                    var isFirst = true
+                    for (target in targets.values) {
+                        if (!isFirst) completeLine(",")
+                        target.apply {
+                            startLine("entry($sourceName.class, ")
+                            appendLine("(p, h) -> $outputName.protect(($sourceName)p, h))")
+                        }
+                        isFirst = false
+                    }
+                }
+                completeLine(";")
+                line()
+
+                // Lookup the relevant factory based on the instance type, if not found return null.
+                parenBlock("public static <T> T tryProtect") {
+                    line("T target,")
+                    line("ProtectedPluginListener listener")
+                }
+                braceBlock {
+                    // Accumulate interfaces from type and all base types
+                    line("HashSet<Class> interfaces = new HashSet<Class>();")
+                    line("Class current = target.getClass();")
+                    braceBlock("while (current != null)") {
+                        braceBlock("for (Class cls : current.getInterfaces())") {
+                            line("interfaces.add(cls);")
+                        }
+                        line("current = current.getSuperclass();")
+                    }
+                    line()
+
+                    // Check if any of the interfaces are marked protectable
+                    line("int candidateCount = 0;")
+                    line("Factory candidateFactory = null;")
+                    braceBlock("for (Class cls : interfaces)") {
+                        line("Factory factory = sFactories.get(cls);")
+                        braceBlock("if (factory != null)") {
+                            line("candidateFactory = factory;")
+                            line("candidateCount++;")
+                        }
+                    }
+                    line()
+
+                    // No match, return null
+                    braceBlock("if (candidateFactory == null)") {
+                        line("Log.i(TAG, \"Wasn't able to wrap \" + target);")
+                        line("return null;")
+                    }
+
+                    // Multiple matches, not supported
+                    braceBlock("if (candidateCount >= 2)") {
+                        var error = "Plugin implements more than one protected interface"
+                        line("throw new UnsupportedOperationException(\"$error\");")
+                    }
+
+                    // Call the factory and wrap the target object
+                    line("return (T)candidateFactory.create(target, listener);")
+                }
+                line()
+
+                // Wraps the target with the appropriate generated proxy if it exists.
+                parenBlock("public static <T> T protectIfAble") {
+                    line("T target,")
+                    line("ProtectedPluginListener listener")
+                }
+                braceBlock {
+                    line("T result = tryProtect(target, listener);")
+                    line("return result != null ? result : target;")
+                }
+                line()
+            }
+        }
+
+        return true
+    }
+}
diff --git a/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/processor/TabbedWriter.kt b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/processor/TabbedWriter.kt
new file mode 100644
index 0000000..941b2c2
--- /dev/null
+++ b/packages/SystemUI/plugin_core/src/com/android/systemui/plugins/processor/TabbedWriter.kt
@@ -0,0 +1,112 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
+ * except in compliance with the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under the
+ * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+package com.android.systemui.plugins.processor
+
+import java.io.BufferedWriter
+import java.io.Writer
+
+/**
+ * [TabbedWriter] is a convience class which tracks and writes correctly tabbed lines for generating
+ * source files. These files don't need to be correctly tabbed as they're ephemeral and not part of
+ * the source tree, but correct tabbing makes debugging much easier when the build fails.
+ */
+class TabbedWriter(writer: Writer) : AutoCloseable {
+    private val target = BufferedWriter(writer)
+    private var isInProgress = false
+    var tabCount: Int = 0
+        private set
+
+    override fun close() = target.close()
+
+    fun line() {
+        target.newLine()
+        isInProgress = false
+    }
+
+    fun line(str: String) {
+        if (isInProgress) {
+            target.newLine()
+        }
+
+        target.append("    ".repeat(tabCount))
+        target.append(str)
+        target.newLine()
+        isInProgress = false
+    }
+
+    fun completeLine(str: String) {
+        if (!isInProgress) {
+            target.newLine()
+            target.append("    ".repeat(tabCount))
+        }
+
+        target.append(str)
+        target.newLine()
+        isInProgress = false
+    }
+
+    fun startLine(str: String) {
+        if (isInProgress) {
+            target.newLine()
+        }
+
+        target.append("    ".repeat(tabCount))
+        target.append(str)
+        isInProgress = true
+    }
+
+    fun appendLine(str: String) {
+        if (!isInProgress) {
+            target.append("    ".repeat(tabCount))
+        }
+
+        target.append(str)
+        isInProgress = true
+    }
+
+    fun braceBlock(str: String = "", write: TabbedWriter.() -> Unit) {
+        block(str, " {", "}", true, write)
+    }
+
+    fun parenBlock(str: String = "", write: TabbedWriter.() -> Unit) {
+        block(str, "(", ")", false, write)
+    }
+
+    private fun block(
+        str: String,
+        start: String,
+        end: String,
+        newLineForEnd: Boolean,
+        write: TabbedWriter.() -> Unit,
+    ) {
+        appendLine(str)
+        completeLine(start)
+
+        tabCount++
+        this.write()
+        tabCount--
+
+        if (newLineForEnd) {
+            line(end)
+        } else {
+            startLine(end)
+        }
+    }
+
+    companion object {
+        fun writeTo(writer: Writer, write: TabbedWriter.() -> Unit) {
+            TabbedWriter(writer).use { it.write() }
+        }
+    }
+}
diff --git a/packages/SystemUI/shared/src/com/android/systemui/shared/plugins/PluginInstance.java b/packages/SystemUI/shared/src/com/android/systemui/shared/plugins/PluginInstance.java
index 87cc86f..8298397 100644
--- a/packages/SystemUI/shared/src/com/android/systemui/shared/plugins/PluginInstance.java
+++ b/packages/SystemUI/shared/src/com/android/systemui/shared/plugins/PluginInstance.java
@@ -32,6 +32,9 @@
 import com.android.systemui.plugins.PluginFragment;
 import com.android.systemui.plugins.PluginLifecycleManager;
 import com.android.systemui.plugins.PluginListener;
+import com.android.systemui.plugins.PluginProtector;
+import com.android.systemui.plugins.PluginWrapper;
+import com.android.systemui.plugins.ProtectedPluginListener;
 
 import dalvik.system.PathClassLoader;
 
@@ -49,7 +52,8 @@
  *
  * @param <T> The type of plugin that this contains.
  */
-public class PluginInstance<T extends Plugin> implements PluginLifecycleManager {
+public class PluginInstance<T extends Plugin>
+        implements PluginLifecycleManager, ProtectedPluginListener {
     private static final String TAG = "PluginInstance";
 
     private final Context mAppContext;
@@ -58,6 +62,7 @@
     private final PluginFactory<T> mPluginFactory;
     private final String mTag;
 
+    private boolean mHasError = false;
     private BiConsumer<String, String> mLogConsumer = null;
     private Context mPluginContext;
     private T mPlugin;
@@ -87,6 +92,11 @@
         return mTag;
     }
 
+    /** */
+    public boolean hasError() {
+        return mHasError;
+    }
+
     public void setLogFunc(BiConsumer logConsumer) {
         mLogConsumer = logConsumer;
     }
@@ -97,8 +107,21 @@
         }
     }
 
+    @Override
+    public synchronized boolean onFail(String className, String methodName, LinkageError failure) {
+        mHasError = true;
+        unloadPlugin();
+        mListener.onPluginDetached(this);
+        return true;
+    }
+
     /** Alerts listener and plugin that the plugin has been created. */
     public synchronized void onCreate() {
+        if (mHasError) {
+            log("Previous LinkageError detected for plugin class");
+            return;
+        }
+
         boolean loadPlugin = mListener.onPluginAttached(this);
         if (!loadPlugin) {
             if (mPlugin != null) {
@@ -109,13 +132,17 @@
         }
 
         if (mPlugin == null) {
-            log("onCreate auto-load");
+            log("onCreate: auto-load");
             loadPlugin();
             return;
         }
 
+        if (!checkVersion()) {
+            log("onCreate: version check failed");
+            return;
+        }
+
         log("onCreate: load callbacks");
-        mPluginFactory.checkVersion(mPlugin);
         if (!(mPlugin instanceof PluginFragment)) {
             // Only call onCreate for plugins that aren't fragments, as fragments
             // will get the onCreate as part of the fragment lifecycle.
@@ -126,6 +153,12 @@
 
     /** Alerts listener and plugin that the plugin is being shutdown. */
     public synchronized void onDestroy() {
+        if (mHasError) {
+            // Detached in error handler
+            log("onDestroy - no-op");
+            return;
+        }
+
         log("onDestroy");
         unloadPlugin();
         mListener.onPluginDetached(this);
@@ -134,28 +167,37 @@
     /** Returns the current plugin instance (if it is loaded). */
     @Nullable
     public T getPlugin() {
-        return mPlugin;
+        return mHasError ? null : mPlugin;
     }
 
     /**
      * Loads and creates the plugin if it does not exist.
      */
     public synchronized void loadPlugin() {
+        if (mHasError) {
+            log("Previous LinkageError detected for plugin class");
+            return;
+        }
+
         if (mPlugin != null) {
             log("Load request when already loaded");
             return;
         }
 
         // Both of these calls take about 1 - 1.5 seconds in test runs
-        mPlugin = mPluginFactory.createPlugin();
+        mPlugin = mPluginFactory.createPlugin(this);
         mPluginContext = mPluginFactory.createPluginContext();
         if (mPlugin == null || mPluginContext == null) {
             Log.e(mTag, "Requested load, but failed");
             return;
         }
 
+        if (!checkVersion()) {
+            log("loadPlugin: version check failed");
+            return;
+        }
+
         log("Loaded plugin; running callbacks");
-        mPluginFactory.checkVersion(mPlugin);
         if (!(mPlugin instanceof PluginFragment)) {
             // Only call onCreate for plugins that aren't fragments, as fragments
             // will get the onCreate as part of the fragment lifecycle.
@@ -165,6 +207,29 @@
     }
 
     /**
+     * Checks the plugin version, and permanently destroys the plugin instance on a failure
+     */
+    private synchronized boolean checkVersion() {
+        if (mHasError) {
+            return false;
+        }
+
+        if (mPlugin == null) {
+            return true;
+        }
+
+        if (mPluginFactory.checkVersion(mPlugin)) {
+            return true;
+        }
+
+        Log.wtf(TAG, "Version check failed for " + mPlugin.getClass().getSimpleName());
+        mHasError = true;
+        unloadPlugin();
+        mListener.onPluginDetached(this);
+        return false;
+    }
+
+    /**
      * Unloads and destroys the current plugin instance if it exists.
      *
      * This will free the associated memory if there are not other references.
@@ -204,7 +269,7 @@
     }
 
     public VersionInfo getVersionInfo() {
-        return mPluginFactory.checkVersion(mPlugin);
+        return mPluginFactory.getVersionInfo(mPlugin);
     }
 
     @VisibleForTesting
@@ -295,16 +360,19 @@
 
     /** Class that compares a plugin class against an implementation for version matching. */
     public interface VersionChecker {
-        /** Compares two plugin classes. */
-        <T extends Plugin> VersionInfo checkVersion(
+        /** Compares two plugin classes. Returns true when match. */
+        <T extends Plugin> boolean checkVersion(
                 Class<T> instanceClass, Class<T> pluginClass, Plugin plugin);
+
+        /** Returns VersionInfo for the target class */
+        <T extends Plugin> VersionInfo getVersionInfo(Class<T> instanceclass);
     }
 
     /** Class that compares a plugin class against an implementation for version matching. */
     public static class VersionCheckerImpl implements VersionChecker {
         @Override
         /** Compares two plugin classes. */
-        public <T extends Plugin> VersionInfo checkVersion(
+        public <T extends Plugin> boolean checkVersion(
                 Class<T> instanceClass, Class<T> pluginClass, Plugin plugin) {
             VersionInfo pluginVersion = new VersionInfo().addClass(pluginClass);
             VersionInfo instanceVersion = new VersionInfo().addClass(instanceClass);
@@ -313,11 +381,17 @@
             } else if (plugin != null) {
                 int fallbackVersion = plugin.getVersion();
                 if (fallbackVersion != pluginVersion.getDefaultVersion()) {
-                    throw new VersionInfo.InvalidVersionException("Invalid legacy version", false);
+                    return false;
                 }
-                return null;
             }
-            return instanceVersion;
+            return true;
+        }
+
+        @Override
+        /** Returns the version info for the class */
+        public <T extends Plugin> VersionInfo getVersionInfo(Class<T> instanceClass) {
+            VersionInfo instanceVersion = new VersionInfo().addClass(instanceClass);
+            return instanceVersion.hasVersionInfo() ? instanceVersion : null;
         }
     }
 
@@ -364,20 +438,16 @@
         }
 
         /** Creates the related plugin object from the factory */
-        public T createPlugin() {
+        public T createPlugin(ProtectedPluginListener listener) {
             try {
                 ClassLoader loader = mClassLoaderFactory.get();
                 Class<T> instanceClass = (Class<T>) Class.forName(
                         mComponentName.getClassName(), true, loader);
                 T result = (T) mInstanceFactory.create(instanceClass);
                 Log.v(TAG, "Created plugin: " + result);
-                return result;
-            } catch (ClassNotFoundException ex) {
-                Log.e(TAG, "Failed to load plugin", ex);
-            } catch (IllegalAccessException ex) {
-                Log.e(TAG, "Failed to load plugin", ex);
-            } catch (InstantiationException ex) {
-                Log.e(TAG, "Failed to load plugin", ex);
+                return PluginProtector.protectIfAble(result, listener);
+            } catch (ReflectiveOperationException ex) {
+                Log.wtf(TAG, "Failed to load plugin", ex);
             }
             return null;
         }
@@ -394,13 +464,27 @@
             return null;
         }
 
-        /** Check Version and create VersionInfo for instance */
-        public VersionInfo checkVersion(T instance) {
+        /** Check Version for the instance */
+        public boolean checkVersion(T instance) {
             if (instance == null) {
-                instance = createPlugin();
+                instance = createPlugin(null);
+            }
+            if (instance instanceof PluginWrapper) {
+                instance = ((PluginWrapper<T>) instance).getPlugin();
             }
             return mVersionChecker.checkVersion(
                     (Class<T>) instance.getClass(), mPluginClass, instance);
         }
+
+        /** Get Version Info for the instance */
+        public VersionInfo getVersionInfo(T instance) {
+            if (instance == null) {
+                instance = createPlugin(null);
+            }
+            if (instance instanceof PluginWrapper) {
+                instance = ((PluginWrapper<T>) instance).getPlugin();
+            }
+            return mVersionChecker.getVersionInfo((Class<T>) instance.getClass());
+        }
     }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/shared/plugins/PluginInstanceTest.java b/packages/SystemUI/tests/src/com/android/systemui/shared/plugins/PluginInstanceTest.java
index 7ddf7a3..93ba8e1 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/shared/plugins/PluginInstanceTest.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/shared/plugins/PluginInstanceTest.java
@@ -35,7 +35,8 @@
 import com.android.systemui.plugins.Plugin;
 import com.android.systemui.plugins.PluginLifecycleManager;
 import com.android.systemui.plugins.PluginListener;
-import com.android.systemui.plugins.annotations.ProvidesInterface;
+import com.android.systemui.plugins.PluginWrapper;
+import com.android.systemui.plugins.TestPlugin;
 import com.android.systemui.plugins.annotations.Requires;
 
 import org.junit.Before;
@@ -60,7 +61,7 @@
 
     private FakeListener mPluginListener;
     private VersionInfo mVersionInfo;
-    private VersionInfo.InvalidVersionException mVersionException;
+    private boolean mVersionCheckResult = true;
     private PluginInstance.VersionChecker mVersionChecker;
 
     private RefCounter mCounter;
@@ -83,14 +84,16 @@
         mVersionInfo = new VersionInfo();
         mVersionChecker = new PluginInstance.VersionChecker() {
             @Override
-            public <T extends Plugin> VersionInfo checkVersion(
+            public <T extends Plugin> boolean checkVersion(
                     Class<T> instanceClass,
                     Class<T> pluginClass,
                     Plugin plugin
             ) {
-                if (mVersionException != null) {
-                    throw mVersionException;
-                }
+                return mVersionCheckResult;
+            }
+
+            @Override
+            public <T extends Plugin> VersionInfo getVersionInfo(Class<T> instanceClass) {
                 return mVersionInfo;
             }
         };
@@ -117,21 +120,29 @@
     }
 
     @Test
-    public void testCorrectVersion() {
-        assertNotNull(mPluginInstance);
+    public void testCorrectVersion_onCreateBuildsPlugin() {
+        mVersionCheckResult = true;
+        assertFalse(mPluginInstance.hasError());
+
+        mPluginInstance.onCreate();
+        assertFalse(mPluginInstance.hasError());
+        assertNotNull(mPluginInstance.getPlugin());
     }
 
-    @Test(expected = VersionInfo.InvalidVersionException.class)
-    public void testIncorrectVersion() throws Exception {
+    @Test
+    public void testIncorrectVersion_destroysPluginInstance() throws Exception {
         ComponentName wrongVersionTestPluginComponentName =
                 new ComponentName(PRIVILEGED_PACKAGE, TestPlugin.class.getName());
 
-        mVersionException = new VersionInfo.InvalidVersionException("test", true);
+        mVersionCheckResult = false;
+        assertFalse(mPluginInstance.hasError());
 
         mPluginInstanceFactory.create(
                 mContext, mAppInfo, wrongVersionTestPluginComponentName,
                 TestPlugin.class, mPluginListener);
         mPluginInstance.onCreate();
+        assertTrue(mPluginInstance.hasError());
+        assertNull(mPluginInstance.getPlugin());
     }
 
     @Test
@@ -139,7 +150,7 @@
         mPluginInstance.onCreate();
         assertEquals(1, mPluginListener.mAttachedCount);
         assertEquals(1, mPluginListener.mLoadCount);
-        assertEquals(mPlugin.get(), mPluginInstance.getPlugin());
+        assertEquals(mPlugin.get(), unwrap(mPluginInstance.getPlugin()));
         assertInstances(1, 1);
     }
 
@@ -176,6 +187,17 @@
     }
 
     @Test
+    public void testLinkageError_caughtAndPluginDestroyed() {
+        mPluginInstance.onCreate();
+        assertFalse(mPluginInstance.hasError());
+
+        Object result = mPluginInstance.getPlugin().methodThrowsError();
+        assertNotNull(result);  // Wrapper function should return non-null;
+        assertTrue(mPluginInstance.hasError());
+        assertNull(mPluginInstance.getPlugin());
+    }
+
+    @Test
     public void testLoadUnloadSimultaneous_HoldsUnload() throws Throwable {
         final Semaphore loadLock = new Semaphore(1);
         final Semaphore unloadLock = new Semaphore(1);
@@ -232,6 +254,13 @@
         assertNull(mPluginInstance.getPlugin());
     }
 
+    private static <T> T unwrap(T plugin) {
+        if (plugin instanceof PluginWrapper) {
+            return ((PluginWrapper<T>) plugin).getPlugin();
+        }
+        return plugin;
+    }
+
     private boolean getLock(Semaphore lock, long millis) {
         try {
             return lock.tryAcquire(millis, TimeUnit.MILLISECONDS);
@@ -243,14 +272,6 @@
         }
     }
 
-    // This target class doesn't matter, it just needs to have a Requires to hit the flow where
-    // the mock version info is called.
-    @ProvidesInterface(action = TestPlugin.ACTION, version = TestPlugin.VERSION)
-    public interface TestPlugin extends Plugin {
-        int VERSION = 1;
-        String ACTION = "testAction";
-    }
-
     private void assertInstances(int allocated, int created) {
         // If there are more than the expected number of allocated instances, then we run the
         // garbage collector to finalize and deallocate any outstanding non-referenced instances.
@@ -300,6 +321,11 @@
         public void onDestroy() {
             mCounter.mCreatedInstances.getAndDecrement();
         }
+
+        @Override
+        public Object methodThrowsError() {
+            throw new LinkageError();
+        }
     }
 
     public class FakeListener implements PluginListener<TestPlugin> {
@@ -337,7 +363,7 @@
             mLoadCount++;
             TestPlugin expectedPlugin = PluginInstanceTest.this.mPlugin.get();
             if (expectedPlugin != null) {
-                assertEquals(expectedPlugin, plugin);
+                assertEquals(expectedPlugin, unwrap(plugin));
             }
             Context expectedContext = PluginInstanceTest.this.mPluginContext.get();
             if (expectedContext != null) {
@@ -357,7 +383,7 @@
             mUnloadCount++;
             TestPlugin expectedPlugin = PluginInstanceTest.this.mPlugin.get();
             if (expectedPlugin != null) {
-                assertEquals(expectedPlugin, plugin);
+                assertEquals(expectedPlugin, unwrap(plugin));
             }
             assertEquals(PluginInstanceTest.this.mPluginInstance, manager);
             if (mOnUnload != null) {