diff --git a/packages/SystemUI/Android.mk b/packages/SystemUI/Android.mk index 8c1ce2450a90a..71bfe8549185f 100644 --- a/packages/SystemUI/Android.mk +++ b/packages/SystemUI/Android.mk @@ -23,6 +23,7 @@ LOCAL_MODULE_TAGS := optional LOCAL_SRC_FILES := $(call all-java-files-under, src) $(call all-Iaidl-files-under, src) LOCAL_STATIC_ANDROID_LIBRARIES := \ + SystemUIPluginLib \ Keyguard \ android-support-v7-recyclerview \ android-support-v7-preference \ diff --git a/packages/SystemUI/AndroidManifest.xml b/packages/SystemUI/AndroidManifest.xml index 3cc16de415a38..8ed1be592ea40 100644 --- a/packages/SystemUI/AndroidManifest.xml +++ b/packages/SystemUI/AndroidManifest.xml @@ -138,6 +138,9 @@ android:protectionLevel="signature" /> + + diff --git a/packages/SystemUI/plugin/Android.mk b/packages/SystemUI/plugin/Android.mk new file mode 100644 index 0000000000000..86527db878c63 --- /dev/null +++ b/packages/SystemUI/plugin/Android.mk @@ -0,0 +1,29 @@ +# Copyright (C) 2016 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. + +LOCAL_PATH := $(call my-dir) +include $(CLEAR_VARS) + +LOCAL_USE_AAPT2 := true + +LOCAL_MODULE_TAGS := optional + +LOCAL_MODULE := SystemUIPluginLib + +LOCAL_SRC_FILES := $(call all-java-files-under, src) + +LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res +LOCAL_JAR_EXCLUDE_FILES := none + +include $(BUILD_STATIC_JAVA_LIBRARY) diff --git a/packages/SystemUI/plugin/AndroidManifest.xml b/packages/SystemUI/plugin/AndroidManifest.xml new file mode 100644 index 0000000000000..7c057dc78ac77 --- /dev/null +++ b/packages/SystemUI/plugin/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/packages/SystemUI/plugin/ExamplePlugin/Android.mk b/packages/SystemUI/plugin/ExamplePlugin/Android.mk new file mode 100644 index 0000000000000..4c82c7505ad3e --- /dev/null +++ b/packages/SystemUI/plugin/ExamplePlugin/Android.mk @@ -0,0 +1,15 @@ +LOCAL_PATH := $(call my-dir) +include $(CLEAR_VARS) + +LOCAL_USE_AAPT2 := true + +LOCAL_PACKAGE_NAME := ExamplePlugin + +LOCAL_JAVA_LIBRARIES := SystemUIPluginLib + +LOCAL_CERTIFICATE := platform +LOCAL_PROGUARD_ENABLED := disabled + +LOCAL_SRC_FILES := $(call all-java-files-under, src) + +include $(BUILD_PACKAGE) diff --git a/packages/SystemUI/plugin/ExamplePlugin/AndroidManifest.xml b/packages/SystemUI/plugin/ExamplePlugin/AndroidManifest.xml new file mode 100644 index 0000000000000..bd2c71c38f5a2 --- /dev/null +++ b/packages/SystemUI/plugin/ExamplePlugin/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + diff --git a/packages/SystemUI/plugin/ExamplePlugin/res/layout/colored_overlay.xml b/packages/SystemUI/plugin/ExamplePlugin/res/layout/colored_overlay.xml new file mode 100644 index 0000000000000..b2910cb19b7cb --- /dev/null +++ b/packages/SystemUI/plugin/ExamplePlugin/res/layout/colored_overlay.xml @@ -0,0 +1,22 @@ + + + + diff --git a/packages/SystemUI/plugin/ExamplePlugin/src/com/android/systemui/plugin/testoverlayplugin/CustomView.java b/packages/SystemUI/plugin/ExamplePlugin/src/com/android/systemui/plugin/testoverlayplugin/CustomView.java new file mode 100644 index 0000000000000..5fdbbf989b2bf --- /dev/null +++ b/packages/SystemUI/plugin/ExamplePlugin/src/com/android/systemui/plugin/testoverlayplugin/CustomView.java @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2016 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.plugin.testoverlayplugin; + +import android.annotation.Nullable; +import android.content.Context; +import android.util.AttributeSet; +import android.util.Log; +import android.view.View; + +/** + * View with some logging to show that its being run. + */ +public class CustomView extends View { + + private static final String TAG = "CustomView"; + + public CustomView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + Log.d(TAG, "new instance"); + } + + @Override + protected void onAttachedToWindow() { + super.onAttachedToWindow(); + Log.d(TAG, "onAttachedToWindow"); + } + + @Override + protected void onDetachedFromWindow() { + super.onDetachedFromWindow(); + Log.d(TAG, "onDetachedFromWindow"); + } +} diff --git a/packages/SystemUI/plugin/ExamplePlugin/src/com/android/systemui/plugin/testoverlayplugin/SampleOverlayPlugin.java b/packages/SystemUI/plugin/ExamplePlugin/src/com/android/systemui/plugin/testoverlayplugin/SampleOverlayPlugin.java new file mode 100644 index 0000000000000..a2f84dc7af2a4 --- /dev/null +++ b/packages/SystemUI/plugin/ExamplePlugin/src/com/android/systemui/plugin/testoverlayplugin/SampleOverlayPlugin.java @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2016 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.plugin.testoverlayplugin; + +import android.content.Context; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import com.android.systemui.plugins.OverlayPlugin; + +public class SampleOverlayPlugin implements OverlayPlugin { + private static final String TAG = "SampleOverlayPlugin"; + private Context mPluginContext; + + private View mStatusBarView; + private View mNavBarView; + + @Override + public int getVersion() { + Log.d(TAG, "getVersion " + VERSION); + return VERSION; + } + + @Override + public void onCreate(Context sysuiContext, Context pluginContext) { + Log.d(TAG, "onCreate"); + mPluginContext = pluginContext; + } + + @Override + public void onDestroy() { + Log.d(TAG, "onDestroy"); + if (mStatusBarView != null) { + mStatusBarView.post( + () -> ((ViewGroup) mStatusBarView.getParent()).removeView(mStatusBarView)); + } + if (mNavBarView != null) { + mNavBarView.post(() -> ((ViewGroup) mNavBarView.getParent()).removeView(mNavBarView)); + } + } + + @Override + public void setup(View statusBar, View navBar) { + Log.d(TAG, "Setup"); + + if (statusBar instanceof ViewGroup) { + mStatusBarView = LayoutInflater.from(mPluginContext) + .inflate(R.layout.colored_overlay, (ViewGroup) statusBar, false); + ((ViewGroup) statusBar).addView(mStatusBarView); + } + if (navBar instanceof ViewGroup) { + mNavBarView = LayoutInflater.from(mPluginContext) + .inflate(R.layout.colored_overlay, (ViewGroup) navBar, false); + ((ViewGroup) navBar).addView(mNavBarView); + } + } +} diff --git a/packages/SystemUI/plugin/src/com/android/systemui/plugins/OverlayPlugin.java b/packages/SystemUI/plugin/src/com/android/systemui/plugins/OverlayPlugin.java new file mode 100644 index 0000000000000..91a260435eaa9 --- /dev/null +++ b/packages/SystemUI/plugin/src/com/android/systemui/plugins/OverlayPlugin.java @@ -0,0 +1,24 @@ +/* + * Copyright (C) 2016 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 android.view.View; + +public interface OverlayPlugin extends Plugin { + + String ACTION = "com.android.systemui.action.PLUGIN_OVERLAY"; + int VERSION = 1; + + void setup(View statusBar, View navBar); +} diff --git a/packages/SystemUI/plugin/src/com/android/systemui/plugins/Plugin.java b/packages/SystemUI/plugin/src/com/android/systemui/plugins/Plugin.java new file mode 100644 index 0000000000000..b31b199376dea --- /dev/null +++ b/packages/SystemUI/plugin/src/com/android/systemui/plugins/Plugin.java @@ -0,0 +1,132 @@ +/* + * Copyright (C) 2016 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 android.content.Context; + +/** + * Plugins are separate APKs that + * are expected to implement interfaces provided by SystemUI. Their + * code is dynamically loaded into the SysUI process which can allow + * for multiple prototypes to be created and run on a single android + * build. + * + * PluginLifecycle: + *
+ *
+ * plugin.onCreate(Context sysuiContext, Context pluginContext);
+ * --- This is always called before any other calls
+ *
+ * pluginListener.onPluginConnected(Plugin p);
+ * --- This lets the plugin hook know that a plugin is now connected.
+ *
+ * ** Any other calls back and forth between sysui/plugin **
+ *
+ * pluginListener.onPluginDisconnected(Plugin p);
+ * --- Lets the plugin hook know that it should stop interacting with
+ *     this plugin and drop all references to it.
+ *
+ * plugin.onDestroy();
+ * --- Finally the plugin can perform any cleanup to ensure that its not
+ *     leaking into the SysUI process.
+ *
+ * Any time a plugin APK is updated the plugin is destroyed and recreated
+ * to load the new code/resources.
+ *
+ * 
+ * + * Creating plugin hooks: + * + * To create a plugin hook, first create an interface in + * frameworks/base/packages/SystemUI/plugin that extends Plugin. + * Include in it any hooks you want to be able to call into from + * sysui and create callback interfaces for anything you need to + * pass through into the plugin. + * + * Then to attach to any plugins simply add a plugin listener and + * onPluginConnected will get called whenever new plugins are installed, + * updated, or enabled. Like this example from SystemUIApplication: + * + *
+ * {@literal
+ * PluginManager.getInstance(this).addPluginListener(OverlayPlugin.COMPONENT,
+ *        new PluginListener() {
+ *        @Override
+ *        public void onPluginConnected(OverlayPlugin plugin) {
+ *            PhoneStatusBar phoneStatusBar = getComponent(PhoneStatusBar.class);
+ *            if (phoneStatusBar != null) {
+ *                plugin.setup(phoneStatusBar.getStatusBarWindow(),
+ *                phoneStatusBar.getNavigationBarView());
+ *            }
+ *        }
+ * }, OverlayPlugin.VERSION, true /* Allow multiple plugins *\/);
+ * }
+ * 
+ * Note the VERSION included here. Any time incompatible changes in the + * interface are made, this version should be changed to ensure old plugins + * aren't accidentally loaded. Since the plugin library is provided by + * SystemUI, default implementations can be added for new methods to avoid + * version changes when possible. + * + * Implementing a Plugin: + * + * See the ExamplePlugin for an example Android.mk on how to compile + * a plugin. Note that SystemUILib is not static for plugins, its classes + * are provided by SystemUI. + * + * Plugin security is based around a signature permission, so plugins must + * hold the following permission in their manifest. + * + *
+ * {@literal
+ * 
+ * }
+ * 
+ * + * A plugin is found through a querying for services, so to let SysUI know + * about it, create a service with a name that points at your implementation + * of the plugin interface with the action accompanying it: + * + *
+ * {@literal
+ * 
+ *    
+ *        
+ *    
+ * 
+ * }
+ * 
+ */ +public interface Plugin { + + /** + * Should be implemented as the following directly referencing the version constant + * from the plugin interface being implemented, this will allow recompiles to automatically + * pick up the current version. + *
+     * {@literal
+     * public int getVersion() {
+     *     return VERSION;
+     * }
+     * }
+     * @return
+     */
+    int getVersion();
+
+    default void onCreate(Context sysuiContext, Context pluginContext) {
+    }
+
+    default void onDestroy() {
+    }
+}
diff --git a/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginInstanceManager.java b/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginInstanceManager.java
new file mode 100644
index 0000000000000..2a7139c3a74c8
--- /dev/null
+++ b/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginInstanceManager.java
@@ -0,0 +1,342 @@
+/*
+ * Copyright (C) 2016 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 android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Message;
+import android.util.Log;
+import android.view.LayoutInflater;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import dalvik.system.PathClassLoader;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class PluginInstanceManager extends BroadcastReceiver {
+
+    private static final boolean DEBUG = false;
+
+    private static final String TAG = "PluginInstanceManager";
+    private static final String PLUGIN_PERMISSION = "com.android.systemui.permission.PLUGIN";
+
+    private final Context mContext;
+    private final PluginListener mListener;
+    private final String mAction;
+    private final boolean mAllowMultiple;
+    private final int mVersion;
+
+    @VisibleForTesting
+    final MainHandler mMainHandler;
+    @VisibleForTesting
+    final PluginHandler mPluginHandler;
+    private final boolean isDebuggable;
+    private final PackageManager mPm;
+    private final ClassLoaderFactory mClassLoaderFactory;
+
+    PluginInstanceManager(Context context, String action, PluginListener listener,
+            boolean allowMultiple, Looper looper, int version) {
+        this(context, context.getPackageManager(), action, listener, allowMultiple, looper, version,
+                Build.IS_DEBUGGABLE, new ClassLoaderFactory());
+    }
+
+    @VisibleForTesting
+    PluginInstanceManager(Context context, PackageManager pm, String action,
+            PluginListener listener, boolean allowMultiple, Looper looper, int version,
+            boolean debuggable, ClassLoaderFactory classLoaderFactory) {
+        mMainHandler = new MainHandler(Looper.getMainLooper());
+        mPluginHandler = new PluginHandler(looper);
+        mContext = context;
+        mPm = pm;
+        mAction = action;
+        mListener = listener;
+        mAllowMultiple = allowMultiple;
+        mVersion = version;
+        isDebuggable = debuggable;
+        mClassLoaderFactory = classLoaderFactory;
+    }
+
+    public void startListening() {
+        if (DEBUG) Log.d(TAG, "startListening");
+        mPluginHandler.sendEmptyMessage(PluginHandler.QUERY_ALL);
+        IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED);
+        filter.addAction(Intent.ACTION_PACKAGE_CHANGED);
+        filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
+        filter.addDataScheme("package");
+        mContext.registerReceiver(this, filter);
+        filter = new IntentFilter(Intent.ACTION_USER_UNLOCKED);
+        mContext.registerReceiver(this, filter);
+    }
+
+    public void stopListening() {
+        if (DEBUG) Log.d(TAG, "stopListening");
+        ArrayList plugins = new ArrayList<>(mPluginHandler.mPlugins);
+        for (PluginInfo plugin : plugins) {
+            mMainHandler.obtainMessage(MainHandler.PLUGIN_DISCONNECTED,
+                    plugin.mPlugin).sendToTarget();
+        }
+        mContext.unregisterReceiver(this);
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        if (DEBUG) Log.d(TAG, "onReceive " + intent);
+        if (Intent.ACTION_USER_UNLOCKED.equals(intent.getAction())) {
+            mPluginHandler.sendEmptyMessage(PluginHandler.QUERY_ALL);
+        } else {
+            Uri data = intent.getData();
+            String pkgName = data.getEncodedSchemeSpecificPart();
+            mPluginHandler.obtainMessage(PluginHandler.REMOVE_PKG, pkgName).sendToTarget();
+            if (!Intent.ACTION_PACKAGE_REMOVED.equals(intent.getAction())) {
+                mPluginHandler.obtainMessage(PluginHandler.QUERY_PKG, pkgName).sendToTarget();
+            }
+        }
+    }
+
+    public boolean checkAndDisable(String className) {
+        boolean disableAny = false;
+        ArrayList plugins = new ArrayList<>(mPluginHandler.mPlugins);
+        for (PluginInfo info : plugins) {
+            if (className.startsWith(info.mPackage)) {
+                disable(info);
+                disableAny = true;
+            }
+        }
+        return disableAny;
+    }
+
+    public void disableAll() {
+        ArrayList plugins = new ArrayList<>(mPluginHandler.mPlugins);
+        plugins.forEach(this::disable);
+    }
+
+    private void disable(PluginInfo info) {
+        // Live by the sword, die by the sword.
+        // Misbehaving plugins get disabled and won't come back until uninstall/reinstall.
+
+        // If a plugin is detected in the stack of a crash then this will be called for that
+        // plugin, if the plugin causing a crash cannot be identified, they are all disabled
+        // assuming one of them must be bad.
+        Log.w(TAG, "Disabling plugin " + info.mPackage + "/" + info.mClass);
+        mPm.setComponentEnabledSetting(
+                new ComponentName(info.mPackage, info.mClass),
+                PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+                PackageManager.DONT_KILL_APP);
+    }
+
+    private class MainHandler extends Handler {
+        private static final int PLUGIN_CONNECTED = 1;
+        private static final int PLUGIN_DISCONNECTED = 2;
+
+        public MainHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            switch (msg.what) {
+                case PLUGIN_CONNECTED:
+                    if (DEBUG) Log.d(TAG, "onPluginConnected");
+                    PluginInfo info = (PluginInfo) msg.obj;
+                    info.mPlugin.onCreate(mContext, info.mPluginContext);
+                    mListener.onPluginConnected(info.mPlugin);
+                    break;
+                case PLUGIN_DISCONNECTED:
+                    if (DEBUG) Log.d(TAG, "onPluginDisconnected");
+                    mListener.onPluginDisconnected((T) msg.obj);
+                    ((T) msg.obj).onDestroy();
+                    break;
+                default:
+                    super.handleMessage(msg);
+                    break;
+            }
+        }
+    }
+
+    static class ClassLoaderFactory {
+        public ClassLoader createClassLoader(String path, ClassLoader base) {
+            return new PathClassLoader(path, base);
+        }
+    }
+
+    private class PluginHandler extends Handler {
+        private static final int QUERY_ALL = 1;
+        private static final int QUERY_PKG = 2;
+        private static final int REMOVE_PKG = 3;
+
+        private final ArrayList> mPlugins = new ArrayList<>();
+
+        public PluginHandler(Looper looper) {
+            super(looper);
+        }
+
+        @Override
+        public void handleMessage(Message msg) {
+            switch (msg.what) {
+                case QUERY_ALL:
+                    if (DEBUG) Log.d(TAG, "queryAll " + mAction);
+                    for (int i = mPlugins.size() - 1; i >= 0; i--) {
+                        PluginInfo plugin = mPlugins.get(i);
+                        mListener.onPluginDisconnected(plugin.mPlugin);
+                        plugin.mPlugin.onDestroy();
+                    }
+                    mPlugins.clear();
+                    handleQueryPlugins(null);
+                    break;
+                case REMOVE_PKG:
+                    String pkg = (String) msg.obj;
+                    for (int i = mPlugins.size() - 1; i >= 0; i--) {
+                        final PluginInfo plugin = mPlugins.get(i);
+                        if (plugin.mPackage.equals(pkg)) {
+                            mMainHandler.obtainMessage(MainHandler.PLUGIN_DISCONNECTED,
+                                    plugin.mPlugin).sendToTarget();
+                            mPlugins.remove(i);
+                        }
+                    }
+                    break;
+                case QUERY_PKG:
+                    String p = (String) msg.obj;
+                    if (DEBUG) Log.d(TAG, "queryPkg " + p + " " + mAction);
+                    if (mAllowMultiple || (mPlugins.size() == 0)) {
+                        handleQueryPlugins(p);
+                    } else {
+                        if (DEBUG) Log.d(TAG, "Too many of " + mAction);
+                    }
+                    break;
+                default:
+                    super.handleMessage(msg);
+            }
+        }
+
+        private void handleQueryPlugins(String pkgName) {
+            // This isn't actually a service and shouldn't ever be started, but is
+            // a convenient PM based way to manage our plugins.
+            Intent intent = new Intent(mAction);
+            if (pkgName != null) {
+                intent.setPackage(pkgName);
+            }
+            List result =
+                    mPm.queryIntentServices(intent, 0);
+            if (DEBUG) Log.d(TAG, "Found " + result.size() + " plugins");
+            if (result.size() > 1 && !mAllowMultiple) {
+                // TODO: Show warning.
+                Log.w(TAG, "Multiple plugins found for " + mAction);
+                return;
+            }
+            for (ResolveInfo info : result) {
+                ComponentName name = new ComponentName(info.serviceInfo.packageName,
+                        info.serviceInfo.name);
+                PluginInfo t = handleLoadPlugin(name);
+                if (t == null) continue;
+                mMainHandler.obtainMessage(mMainHandler.PLUGIN_CONNECTED, t).sendToTarget();
+                mPlugins.add(t);
+            }
+        }
+
+        protected PluginInfo handleLoadPlugin(ComponentName component) {
+            // This was already checked, but do it again here to make extra extra sure, we don't
+            // use these on production builds.
+            if (!isDebuggable) {
+                // Never ever ever allow these on production builds, they are only for prototyping.
+                Log.d(TAG, "Somehow hit second debuggable check");
+                return null;
+            }
+            String pkg = component.getPackageName();
+            String cls = component.getClassName();
+            try {
+                PackageManager pm = mPm;
+                ApplicationInfo info = pm.getApplicationInfo(pkg, 0);
+                // TODO: This probably isn't needed given that we don't have IGNORE_SECURITY on
+                if (pm.checkPermission(PLUGIN_PERMISSION, pkg)
+                        != PackageManager.PERMISSION_GRANTED) {
+                    Log.d(TAG, "Plugin doesn't have permission: " + pkg);
+                    return null;
+                }
+                // Create our own ClassLoader so we can use our own code as the parent.
+                ClassLoader classLoader = mClassLoaderFactory.createClassLoader(info.sourceDir,
+                        getClass().getClassLoader());
+                Context pluginContext = new PluginContextWrapper(
+                        mContext.createApplicationContext(info, 0), classLoader);
+                Class pluginClass = Class.forName(cls, true, classLoader);
+                T plugin = (T) pluginClass.newInstance();
+                if (plugin.getVersion() != mVersion) {
+                    // TODO: Warn user.
+                    Log.w(TAG, "Plugin has invalid interface version " + plugin.getVersion()
+                            + ", expected " + mVersion);
+                    return null;
+                }
+                if (DEBUG) Log.d(TAG, "createPlugin");
+                return new PluginInfo(pkg, cls, plugin, pluginContext);
+            } catch (Exception e) {
+                Log.w(TAG, "Couldn't load plugin: " + pkg, e);
+                return null;
+            }
+        }
+    }
+
+    public static class PluginContextWrapper extends ContextWrapper {
+        private final ClassLoader mClassLoader;
+        private LayoutInflater mInflater;
+
+        public PluginContextWrapper(Context base, ClassLoader classLoader) {
+            super(base);
+            mClassLoader = classLoader;
+        }
+
+        @Override
+        public ClassLoader getClassLoader() {
+            return mClassLoader;
+        }
+
+        @Override
+        public Object getSystemService(String name) {
+            if (LAYOUT_INFLATER_SERVICE.equals(name)) {
+                if (mInflater == null) {
+                    mInflater = LayoutInflater.from(getBaseContext()).cloneInContext(this);
+                }
+                return mInflater;
+            }
+            return getBaseContext().getSystemService(name);
+        }
+    }
+
+    private static class PluginInfo {
+        private final Context mPluginContext;
+        private T mPlugin;
+        private String mClass;
+        private String mPackage;
+
+        public PluginInfo(String pkg, String cls, T plugin, Context pluginContext) {
+            mPlugin = plugin;
+            mClass = cls;
+            mPackage = pkg;
+            mPluginContext = pluginContext;
+        }
+    }
+}
diff --git a/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginListener.java b/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginListener.java
new file mode 100644
index 0000000000000..b2f92d6c017e9
--- /dev/null
+++ b/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginListener.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2016 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;
+
+/**
+ * Interface for listening to plugins being connected.
+ */
+public interface PluginListener {
+    /**
+     * Called when the plugin has been loaded and is ready to be used.
+     * This may be called multiple times if multiple plugins are allowed.
+     * It may also be called in the future if the plugin package changes
+     * and needs to be reloaded.
+     */
+    void onPluginConnected(T plugin);
+
+    /**
+     * Called when a plugin has been uninstalled/updated and should be removed
+     * from use.
+     */
+    default void onPluginDisconnected(T plugin) {
+        // Optional.
+    }
+}
diff --git a/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginManager.java b/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginManager.java
new file mode 100644
index 0000000000000..aa0b3c5867478
--- /dev/null
+++ b/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginManager.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) 2016 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 android.content.Context;
+import android.os.Build;
+import android.os.HandlerThread;
+import android.os.Looper;
+import android.util.ArrayMap;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+import java.lang.Thread.UncaughtExceptionHandler;
+
+/**
+ * @see Plugin
+ */
+public class PluginManager {
+
+    private static PluginManager sInstance;
+
+    private final HandlerThread mBackgroundThread;
+    private final ArrayMap, PluginInstanceManager> mPluginMap
+            = new ArrayMap<>();
+    private final Context mContext;
+    private final PluginInstanceManagerFactory mFactory;
+    private final boolean isDebuggable;
+
+    private PluginManager(Context context) {
+        this(context, new PluginInstanceManagerFactory(), Build.IS_DEBUGGABLE,
+                Thread.getDefaultUncaughtExceptionHandler());
+    }
+
+    @VisibleForTesting
+    PluginManager(Context context, PluginInstanceManagerFactory factory, boolean debuggable,
+            UncaughtExceptionHandler defaultHandler) {
+        mContext = context;
+        mFactory = factory;
+        mBackgroundThread = new HandlerThread("Plugins");
+        mBackgroundThread.start();
+        isDebuggable = debuggable;
+
+        PluginExceptionHandler uncaughtExceptionHandler = new PluginExceptionHandler(
+                defaultHandler);
+        Thread.setDefaultUncaughtExceptionHandler(uncaughtExceptionHandler);
+    }
+
+    public  void addPluginListener(String action, PluginListener listener,
+            int version) {
+        addPluginListener(action, listener, version, false);
+    }
+
+    public  void addPluginListener(String action, PluginListener listener,
+            int version, boolean allowMultiple) {
+        if (!isDebuggable) {
+            // Never ever ever allow these on production builds, they are only for prototyping.
+            return;
+        }
+        PluginInstanceManager p = mFactory.createPluginInstanceManager(mContext, action, listener,
+                allowMultiple, mBackgroundThread.getLooper(), version);
+        p.startListening();
+        mPluginMap.put(listener, p);
+    }
+
+    public void removePluginListener(PluginListener listener) {
+        if (!isDebuggable) {
+            // Never ever ever allow these on production builds, they are only for prototyping.
+            return;
+        }
+        if (!mPluginMap.containsKey(listener)) return;
+        mPluginMap.remove(listener).stopListening();
+    }
+
+    public static PluginManager getInstance(Context context) {
+        if (sInstance == null) {
+            sInstance = new PluginManager(context.getApplicationContext());
+        }
+        return sInstance;
+    }
+
+    @VisibleForTesting
+    public static class PluginInstanceManagerFactory {
+        public  PluginInstanceManager createPluginInstanceManager(Context context,
+                String action, PluginListener listener, boolean allowMultiple, Looper looper,
+                int version) {
+            return new PluginInstanceManager(context, action, listener, allowMultiple, looper,
+                    version);
+        }
+    }
+
+    private class PluginExceptionHandler implements UncaughtExceptionHandler {
+        private final UncaughtExceptionHandler mHandler;
+
+        private PluginExceptionHandler(UncaughtExceptionHandler handler) {
+            mHandler = handler;
+        }
+
+        @Override
+        public void uncaughtException(Thread thread, Throwable throwable) {
+            // Search for and disable plugins that may have been involved in this crash.
+            boolean disabledAny = checkStack(throwable);
+            if (!disabledAny) {
+                // We couldn't find any plugins involved in this crash, just to be safe
+                // disable all the plugins, so we can be sure that SysUI is running as
+                // best as possible.
+                for (PluginInstanceManager manager : mPluginMap.values()) {
+                    manager.disableAll();
+                }
+            }
+
+            // Run the normal exception handler so we can crash and cleanup our state.
+            mHandler.uncaughtException(thread, throwable);
+        }
+
+        private boolean checkStack(Throwable throwable) {
+            if (throwable == null) return false;
+            boolean disabledAny = false;
+            for (StackTraceElement element : throwable.getStackTrace()) {
+                for (PluginInstanceManager manager : mPluginMap.values()) {
+                    disabledAny |= manager.checkAndDisable(element.getClassName());
+                }
+            }
+            return disabledAny | checkStack(throwable.getCause());
+        }
+    }
+}
diff --git a/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginUtils.java b/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginUtils.java
new file mode 100644
index 0000000000000..af49d43c97e15
--- /dev/null
+++ b/packages/SystemUI/plugin/src/com/android/systemui/plugins/PluginUtils.java
@@ -0,0 +1,27 @@
+/*
+ * Copyright (C) 2016 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 android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+
+public class PluginUtils {
+
+    public static void setId(Context sysuiContext, View view, String id) {
+        int i = sysuiContext.getResources().getIdentifier(id, "id", sysuiContext.getPackageName());
+        view.setId(i);
+    }
+}
diff --git a/packages/SystemUI/proguard.flags b/packages/SystemUI/proguard.flags
index 9182f7ef01265..364885a2d6ebb 100644
--- a/packages/SystemUI/proguard.flags
+++ b/packages/SystemUI/proguard.flags
@@ -37,3 +37,6 @@
 
 -keep class ** extends android.support.v14.preference.PreferenceFragment
 -keep class com.android.systemui.tuner.*
+-keep class com.android.systemui.plugins.** {
+    public protected **;
+}
diff --git a/packages/SystemUI/src/com/android/systemui/SystemUIApplication.java b/packages/SystemUI/src/com/android/systemui/SystemUIApplication.java
index 4b9ae2a37c6f7..e300aff96c9f0 100644
--- a/packages/SystemUI/src/com/android/systemui/SystemUIApplication.java
+++ b/packages/SystemUI/src/com/android/systemui/SystemUIApplication.java
@@ -29,7 +29,11 @@ import android.os.SystemProperties;
 import android.os.UserHandle;
 import android.util.Log;
 
+import com.android.systemui.plugins.OverlayPlugin;
+import com.android.systemui.plugins.PluginListener;
+import com.android.systemui.plugins.PluginManager;
 import com.android.systemui.stackdivider.Divider;
+import com.android.systemui.statusbar.phone.PhoneStatusBar;
 
 import java.util.HashMap;
 import java.util.Map;
@@ -183,6 +187,18 @@ public class SystemUIApplication extends Application {
                 mServices[i].onBootCompleted();
             }
         }
+        PluginManager.getInstance(this).addPluginListener(OverlayPlugin.ACTION,
+                new PluginListener() {
+            @Override
+            public void onPluginConnected(OverlayPlugin plugin) {
+                PhoneStatusBar phoneStatusBar = getComponent(PhoneStatusBar.class);
+                if (phoneStatusBar != null) {
+                    plugin.setup(phoneStatusBar.getStatusBarWindow(),
+                            phoneStatusBar.getNavigationBarView());
+                }
+            }
+        }, OverlayPlugin.VERSION, true /* Allow multiple plugins */);
+
         mServicesStarted = true;
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/NavigationBarView.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/NavigationBarView.java
index 222e8dfa2c827..9e5b881f8a598 100644
--- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/NavigationBarView.java
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/NavigationBarView.java
@@ -43,6 +43,7 @@ import android.view.ViewGroup;
 import android.view.WindowManager;
 import android.view.WindowManagerGlobal;
 import android.view.inputmethod.InputMethodManager;
+import android.widget.FrameLayout;
 import android.widget.LinearLayout;
 import com.android.systemui.R;
 import com.android.systemui.RecentsComponent;
@@ -52,7 +53,7 @@ import com.android.systemui.statusbar.policy.DeadZone;
 import java.io.FileDescriptor;
 import java.io.PrintWriter;
 
-public class NavigationBarView extends LinearLayout {
+public class NavigationBarView extends FrameLayout {
     final static boolean DEBUG = false;
     final static String TAG = "StatusBar/NavBarView";
 
diff --git a/packages/SystemUI/tests/Android.mk b/packages/SystemUI/tests/Android.mk
index 5d6ac12ac9459..23967aa7023be 100644
--- a/packages/SystemUI/tests/Android.mk
+++ b/packages/SystemUI/tests/Android.mk
@@ -34,6 +34,7 @@ LOCAL_RESOURCE_DIR := $(LOCAL_PATH)/res \
     frameworks/base/packages/SystemUI/res \
 
 LOCAL_STATIC_ANDROID_LIBRARIES := \
+    SystemUIPluginLib \
     Keyguard \
     android-support-v7-recyclerview \
     android-support-v7-preference \
diff --git a/packages/SystemUI/tests/src/com/android/systemui/SysuiTestCase.java b/packages/SystemUI/tests/src/com/android/systemui/SysuiTestCase.java
index 869805edd3602..d943eb6a1a609 100644
--- a/packages/SystemUI/tests/src/com/android/systemui/SysuiTestCase.java
+++ b/packages/SystemUI/tests/src/com/android/systemui/SysuiTestCase.java
@@ -17,13 +17,17 @@ package com.android.systemui;
 
 import android.content.Context;
 import android.support.test.InstrumentationRegistry;
-import android.test.AndroidTestCase;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.MessageQueue;
 import org.junit.Before;
 
 /**
  * Base class that does System UI specific setup.
  */
 public class SysuiTestCase {
+
+    private Handler mHandler;
     protected Context mContext;
 
     @Before
@@ -34,4 +38,65 @@ public class SysuiTestCase {
     protected Context getContext() {
         return mContext;
     }
+
+    protected void waitForIdleSync() {
+        if (mHandler == null) {
+            mHandler = new Handler(Looper.getMainLooper());
+        }
+        waitForIdleSync(mHandler);
+    }
+
+    protected void waitForIdleSync(Handler h) {
+        validateThread(h.getLooper());
+        Idler idler = new Idler(null);
+        h.getLooper().getQueue().addIdleHandler(idler);
+        // Ensure we are non-idle, so the idle handler can run.
+        h.post(new EmptyRunnable());
+        idler.waitForIdle();
+    }
+
+    private static final void validateThread(Looper l) {
+        if (Looper.myLooper() == l) {
+            throw new RuntimeException(
+                "This method can not be called from the looper being synced");
+        }
+    }
+
+    public static final class EmptyRunnable implements Runnable {
+        public void run() {
+        }
+    }
+
+    public static final class Idler implements MessageQueue.IdleHandler {
+        private final Runnable mCallback;
+        private boolean mIdle;
+
+        public Idler(Runnable callback) {
+            mCallback = callback;
+            mIdle = false;
+        }
+
+        @Override
+        public boolean queueIdle() {
+            if (mCallback != null) {
+                mCallback.run();
+            }
+            synchronized (this) {
+                mIdle = true;
+                notifyAll();
+            }
+            return false;
+        }
+
+        public void waitForIdle() {
+            synchronized (this) {
+                while (!mIdle) {
+                    try {
+                        wait();
+                    } catch (InterruptedException e) {
+                    }
+                }
+            }
+        }
+    }
 }
diff --git a/packages/SystemUI/tests/src/com/android/systemui/plugins/PluginInstanceManagerTest.java b/packages/SystemUI/tests/src/com/android/systemui/plugins/PluginInstanceManagerTest.java
new file mode 100644
index 0000000000000..ab7de39a49b6b
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/plugins/PluginInstanceManagerTest.java
@@ -0,0 +1,284 @@
+/*
+ * Copyright (C) 2016 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 static junit.framework.Assert.assertFalse;
+import static junit.framework.Assert.assertTrue;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.app.Activity;
+import android.content.BroadcastReceiver;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.pm.ResolveInfo;
+import android.content.pm.ServiceInfo;
+import android.net.Uri;
+import android.os.HandlerThread;
+import android.support.test.runner.AndroidJUnit4;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.plugins.PluginInstanceManager.ClassLoaderFactory;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class PluginInstanceManagerTest extends SysuiTestCase {
+
+    // Static since the plugin needs to be generated by the PluginInstanceManager using newInstance.
+    private static Plugin sMockPlugin;
+
+    private HandlerThread mHandlerThread;
+    private Context mContextWrapper;
+    private PackageManager mMockPm;
+    private PluginListener mMockListener;
+    private PluginInstanceManager mPluginInstanceManager;
+
+    @Before
+    public void setup() throws Exception {
+        mHandlerThread = new HandlerThread("test_thread");
+        mHandlerThread.start();
+        mContextWrapper = new MyContextWrapper(getContext());
+        mMockPm = mock(PackageManager.class);
+        mMockListener = mock(PluginListener.class);
+        mPluginInstanceManager = new PluginInstanceManager(mContextWrapper, mMockPm, "myAction",
+                mMockListener, true, mHandlerThread.getLooper(), 1, true,
+                new TestClassLoaderFactory());
+        sMockPlugin = mock(Plugin.class);
+        when(sMockPlugin.getVersion()).thenReturn(1);
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        mHandlerThread.quit();
+        sMockPlugin = null;
+    }
+
+    @Test
+    public void testNoPlugins() {
+        when(mMockPm.queryIntentServices(Mockito.any(), Mockito.anyInt())).thenReturn(
+                Collections.emptyList());
+        mPluginInstanceManager.startListening();
+
+        waitForIdleSync(mPluginInstanceManager.mPluginHandler);
+        waitForIdleSync(mPluginInstanceManager.mMainHandler);
+
+        verify(mMockListener, Mockito.never()).onPluginConnected(
+                ArgumentCaptor.forClass(Plugin.class).capture());
+    }
+
+    @Test
+    public void testPluginCreate() {
+        createPlugin();
+
+        // Verify startup lifecycle
+        verify(sMockPlugin).onCreate(ArgumentCaptor.forClass(Context.class).capture(),
+                ArgumentCaptor.forClass(Context.class).capture());
+        verify(mMockListener).onPluginConnected(ArgumentCaptor.forClass(Plugin.class).capture());
+    }
+
+    @Test
+    public void testPluginDestroy() {
+        createPlugin(); // Get into valid created state.
+
+        mPluginInstanceManager.stopListening();
+
+        waitForIdleSync(mPluginInstanceManager.mPluginHandler);
+        waitForIdleSync(mPluginInstanceManager.mMainHandler);
+
+        // Verify shutdown lifecycle
+        verify(mMockListener).onPluginDisconnected(ArgumentCaptor.forClass(Plugin.class).capture());
+        verify(sMockPlugin).onDestroy();
+    }
+
+    @Test
+    public void testIncorrectVersion() {
+        setupFakePmQuery();
+        when(sMockPlugin.getVersion()).thenReturn(2);
+
+        mPluginInstanceManager.startListening();
+
+        waitForIdleSync(mPluginInstanceManager.mPluginHandler);
+        waitForIdleSync(mPluginInstanceManager.mMainHandler);
+
+        // Plugin shouldn't be connected because it is the wrong version.
+        verify(mMockListener, Mockito.never()).onPluginConnected(
+                ArgumentCaptor.forClass(Plugin.class).capture());
+    }
+
+    @Test
+    public void testReloadOnChange() {
+        createPlugin(); // Get into valid created state.
+
+        // Send a package changed broadcast.
+        Intent i = new Intent(Intent.ACTION_PACKAGE_CHANGED,
+                Uri.fromParts("package", "com.android.systemui", null));
+        mPluginInstanceManager.onReceive(mContextWrapper, i);
+
+        waitForIdleSync(mPluginInstanceManager.mPluginHandler);
+        waitForIdleSync(mPluginInstanceManager.mMainHandler);
+
+        // Verify the old one was destroyed.
+        verify(mMockListener).onPluginDisconnected(ArgumentCaptor.forClass(Plugin.class).capture());
+        verify(sMockPlugin).onDestroy();
+        // Also verify we got a second onCreate.
+        verify(sMockPlugin, Mockito.times(2)).onCreate(
+                ArgumentCaptor.forClass(Context.class).capture(),
+                ArgumentCaptor.forClass(Context.class).capture());
+        verify(mMockListener, Mockito.times(2)).onPluginConnected(
+                ArgumentCaptor.forClass(Plugin.class).capture());
+    }
+
+    @Test
+    public void testNonDebuggable() {
+        // Create a version that thinks the build is not debuggable.
+        mPluginInstanceManager = new PluginInstanceManager(mContextWrapper, mMockPm, "myAction",
+                mMockListener, true, mHandlerThread.getLooper(), 1, false,
+                new TestClassLoaderFactory());
+        setupFakePmQuery();
+
+        mPluginInstanceManager.startListening();
+
+        waitForIdleSync(mPluginInstanceManager.mPluginHandler);
+        waitForIdleSync(mPluginInstanceManager.mMainHandler);;
+
+        // Non-debuggable build should receive no plugins.
+        verify(mMockListener, Mockito.never()).onPluginConnected(
+                ArgumentCaptor.forClass(Plugin.class).capture());
+    }
+
+    @Test
+    public void testCheckAndDisable() {
+        createPlugin(); // Get into valid created state.
+
+        // Start with an unrelated class.
+        boolean result = mPluginInstanceManager.checkAndDisable(Activity.class.getName());
+        assertFalse(result);
+        verify(mMockPm, Mockito.never()).setComponentEnabledSetting(
+                ArgumentCaptor.forClass(ComponentName.class).capture(),
+                ArgumentCaptor.forClass(int.class).capture(),
+                ArgumentCaptor.forClass(int.class).capture());
+
+        // Now hand it a real class and make sure it disables the plugin.
+        result = mPluginInstanceManager.checkAndDisable(TestPlugin.class.getName());
+        assertTrue(result);
+        verify(mMockPm).setComponentEnabledSetting(
+                ArgumentCaptor.forClass(ComponentName.class).capture(),
+                ArgumentCaptor.forClass(int.class).capture(),
+                ArgumentCaptor.forClass(int.class).capture());
+    }
+
+    @Test
+    public void testDisableAll() {
+        createPlugin(); // Get into valid created state.
+
+        mPluginInstanceManager.disableAll();
+
+        verify(mMockPm).setComponentEnabledSetting(
+                ArgumentCaptor.forClass(ComponentName.class).capture(),
+                ArgumentCaptor.forClass(int.class).capture(),
+                ArgumentCaptor.forClass(int.class).capture());
+    }
+
+    private void setupFakePmQuery() {
+        List list = new ArrayList<>();
+        ResolveInfo info = new ResolveInfo();
+        info.serviceInfo = new ServiceInfo();
+        info.serviceInfo.packageName = "com.android.systemui";
+        info.serviceInfo.name = TestPlugin.class.getName();
+        list.add(info);
+        when(mMockPm.queryIntentServices(Mockito.any(), Mockito.anyInt())).thenReturn(list);
+
+        when(mMockPm.checkPermission(Mockito.anyString(), Mockito.anyString())).thenReturn(
+                PackageManager.PERMISSION_GRANTED);
+
+        try {
+            ApplicationInfo appInfo = getContext().getApplicationInfo();
+            when(mMockPm.getApplicationInfo(Mockito.anyString(), Mockito.anyInt())).thenReturn(
+                    appInfo);
+        } catch (NameNotFoundException e) {
+            // Shouldn't be possible, but if it is, we want to fail.
+            throw new RuntimeException(e);
+        }
+    }
+
+    private void createPlugin() {
+        setupFakePmQuery();
+
+        mPluginInstanceManager.startListening();
+
+        waitForIdleSync(mPluginInstanceManager.mPluginHandler);
+        waitForIdleSync(mPluginInstanceManager.mMainHandler);
+    }
+
+    private static class TestClassLoaderFactory extends ClassLoaderFactory {
+        @Override
+        public ClassLoader createClassLoader(String path, ClassLoader base) {
+            return base;
+        }
+    }
+
+    // Real context with no registering/unregistering of receivers.
+    private static class MyContextWrapper extends ContextWrapper {
+        public MyContextWrapper(Context base) {
+            super(base);
+        }
+
+        @Override
+        public Intent registerReceiver(BroadcastReceiver receiver, IntentFilter filter) {
+            return null;
+        }
+
+        @Override
+        public void unregisterReceiver(BroadcastReceiver receiver) {
+        }
+    }
+
+    public static class TestPlugin implements Plugin {
+        @Override
+        public int getVersion() {
+            return sMockPlugin.getVersion();
+        }
+
+        @Override
+        public void onCreate(Context sysuiContext, Context pluginContext) {
+            sMockPlugin.onCreate(sysuiContext, pluginContext);
+        }
+
+        @Override
+        public void onDestroy() {
+            sMockPlugin.onDestroy();
+        }
+    }
+}
diff --git a/packages/SystemUI/tests/src/com/android/systemui/plugins/PluginManagerTest.java b/packages/SystemUI/tests/src/com/android/systemui/plugins/PluginManagerTest.java
new file mode 100644
index 0000000000000..56e742aa06630
--- /dev/null
+++ b/packages/SystemUI/tests/src/com/android/systemui/plugins/PluginManagerTest.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2016 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 static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import android.support.test.runner.AndroidJUnit4;
+import android.test.suitebuilder.annotation.SmallTest;
+
+import com.android.systemui.SysuiTestCase;
+import com.android.systemui.plugins.PluginManager.PluginInstanceManagerFactory;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+import java.lang.Thread.UncaughtExceptionHandler;
+
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class PluginManagerTest extends SysuiTestCase {
+
+    private PluginInstanceManagerFactory mMockFactory;
+    private PluginInstanceManager mMockPluginInstance;
+    private PluginManager mPluginManager;
+    private PluginListener mMockListener;
+
+    private UncaughtExceptionHandler mRealExceptionHandler;
+    private UncaughtExceptionHandler mMockExceptionHandler;
+    private UncaughtExceptionHandler mPluginExceptionHandler;
+
+    @Before
+    public void setup() throws Exception {
+        mRealExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
+        mMockExceptionHandler = mock(UncaughtExceptionHandler.class);
+        mMockFactory = mock(PluginInstanceManagerFactory.class);
+        mMockPluginInstance = mock(PluginInstanceManager.class);
+        when(mMockFactory.createPluginInstanceManager(Mockito.any(), Mockito.any(), Mockito.any(),
+                Mockito.anyBoolean(), Mockito.any(), Mockito.anyInt()))
+                .thenReturn(mMockPluginInstance);
+        mPluginManager = new PluginManager(getContext(), mMockFactory, true, mMockExceptionHandler);
+        resetExceptionHandler();
+        mMockListener = mock(PluginListener.class);
+    }
+
+    @Test
+    public void testAddListener() {
+        mPluginManager.addPluginListener("myAction", mMockListener, 1);
+
+        verify(mMockPluginInstance).startListening();
+    }
+
+    @Test
+    public void testRemoveListener() {
+        mPluginManager.addPluginListener("myAction", mMockListener, 1);
+
+        mPluginManager.removePluginListener(mMockListener);
+        verify(mMockPluginInstance).stopListening();
+    }
+
+    @Test
+    public void testNonDebuggable() {
+        mPluginManager = new PluginManager(getContext(), mMockFactory, false,
+                mMockExceptionHandler);
+        resetExceptionHandler();
+        mPluginManager.addPluginListener("myAction", mMockListener, 1);
+
+        verify(mMockPluginInstance, Mockito.never()).startListening();
+    }
+
+    @Test
+    public void testExceptionHandler_foundPlugin() {
+        mPluginManager.addPluginListener("myAction", mMockListener, 1);
+        when(mMockPluginInstance.checkAndDisable(Mockito.any())).thenReturn(true);
+
+        mPluginExceptionHandler.uncaughtException(Thread.currentThread(), new Throwable());
+
+        verify(mMockPluginInstance, Mockito.atLeastOnce()).checkAndDisable(
+                ArgumentCaptor.forClass(String.class).capture());
+        verify(mMockPluginInstance, Mockito.never()).disableAll();
+        verify(mMockExceptionHandler).uncaughtException(
+                ArgumentCaptor.forClass(Thread.class).capture(),
+                ArgumentCaptor.forClass(Throwable.class).capture());
+    }
+
+    @Test
+    public void testExceptionHandler_noFoundPlugin() {
+        mPluginManager.addPluginListener("myAction", mMockListener, 1);
+        when(mMockPluginInstance.checkAndDisable(Mockito.any())).thenReturn(false);
+
+        mPluginExceptionHandler.uncaughtException(Thread.currentThread(), new Throwable());
+
+        verify(mMockPluginInstance, Mockito.atLeastOnce()).checkAndDisable(
+                ArgumentCaptor.forClass(String.class).capture());
+        verify(mMockPluginInstance).disableAll();
+        verify(mMockExceptionHandler).uncaughtException(
+                ArgumentCaptor.forClass(Thread.class).capture(),
+                ArgumentCaptor.forClass(Throwable.class).capture());
+    }
+
+    private void resetExceptionHandler() {
+        mPluginExceptionHandler = Thread.getDefaultUncaughtExceptionHandler();
+        // Set back the real exception handler so the test can crash if it wants to.
+        Thread.setDefaultUncaughtExceptionHandler(mRealExceptionHandler);
+    }
+}