Bug 1168407 - Implement a bidirectional Java addon interface. f=jchen,r=rnewman,r=mfinkle
authorNick Alexander <nalexander@mozilla.com>
Wed, 17 Jun 2015 21:47:29 -0700
changeset 249651 60db000f3acb5cde64810d3198aee20c89dc1f7c
parent 249650 d99a590f252405640c1b347e97f6a863ff68f35e
child 249652 39cd581fcc1468cf108a0e65c6a3e286505bf72e
push idunknown
push userunknown
push dateunknown
reviewersrnewman, mfinkle
bugs1168407
milestone41.0a1
Bug 1168407 - Implement a bidirectional Java addon interface. f=jchen,r=rnewman,r=mfinkle There are several parts to this ticket: 1) Produce javaaddons-1.0.jar, a standalone JAR defining a (versioned) Java interface suitable for consumption by third-party Java addon implementations. 2) Support the new V1 interface in the JavaAddonManager. 3) Add Robocop JavascriptTests testing the JavaScript message passing interface to and from Java. This patch can be read as "not in tests/" and "everything in tests/".
config/recurse.mk
mobile/android/base/BrowserApp.java
mobile/android/base/EventDispatcher.java
mobile/android/base/JavaAddonManager.java
mobile/android/base/Makefile.in
mobile/android/base/javaaddons/JavaAddonManager.java
mobile/android/base/javaaddons/JavaAddonManagerV1.java
mobile/android/base/moz.build
mobile/android/config/proguard/proguard.cfg
mobile/android/gradle/app/build.gradle
mobile/android/javaaddons/Makefile.in
mobile/android/javaaddons/java/org/mozilla/javaaddons/JavaAddonInterfaceV1.java
mobile/android/javaaddons/moz.build
mobile/android/mach_commands.py
mobile/android/modules/JavaAddonManager.jsm
mobile/android/modules/moz.build
mobile/android/moz.build
mobile/android/tests/browser/robocop/robocop.ini
mobile/android/tests/browser/robocop/roboextender/Makefile.in
mobile/android/tests/browser/robocop/testJavaAddons.java
mobile/android/tests/browser/robocop/testJavaAddons.js
mobile/android/tests/javaaddons/AndroidManifest.xml.in
mobile/android/tests/javaaddons/Makefile.in
mobile/android/tests/javaaddons/moz.build
mobile/android/tests/javaaddons/res/values/strings.xml
mobile/android/tests/javaaddons/src/org/mozilla/javaaddons/test/ClassWithNoRecognizedConstructors.java
mobile/android/tests/javaaddons/src/org/mozilla/javaaddons/test/JavaAddonV0.java
mobile/android/tests/javaaddons/src/org/mozilla/javaaddons/test/JavaAddonV1.java
mobile/android/tests/moz.build
--- a/config/recurse.mk
+++ b/config/recurse.mk
@@ -142,16 +142,20 @@ recurse:
 ifeq (.,$(DEPTH))
 # Interdependencies for parallel export.
 js/xpconnect/src/export: dom/bindings/export xpcom/xpidl/export
 accessible/xpcom/export: xpcom/xpidl/export
 
 # The widget binding generator code is part of the annotationProcessors.
 widget/android/bindings/export: build/annotationProcessors/export
 
+# The roboextender addon includes a classes.dex containing a test Java addon.
+# The test addon must be built first.
+mobile/android/tests/browser/robocop/roboextender/tools: mobile/android/tests/javaaddons/tools
+
 ifdef ENABLE_CLANG_PLUGIN
 $(filter-out build/clang-plugin/%,$(compile_targets)): build/clang-plugin/target build/clang-plugin/tests/target
 build/clang-plugin/tests/target: build/clang-plugin/target
 endif
 
 # Interdependencies that moz.build world don't know about yet for compilation.
 # Note some others are hardcoded or "guessed" in recursivemake.py and emitter.py
 ifeq ($(MOZ_WIDGET_TOOLKIT),gtk3)
--- a/mobile/android/base/BrowserApp.java
+++ b/mobile/android/base/BrowserApp.java
@@ -41,16 +41,17 @@ import org.mozilla.gecko.health.SessionI
 import org.mozilla.gecko.home.BrowserSearch;
 import org.mozilla.gecko.home.HomeBanner;
 import org.mozilla.gecko.home.HomeConfig.PanelType;
 import org.mozilla.gecko.home.HomePager;
 import org.mozilla.gecko.home.HomePager.OnUrlOpenInBackgroundListener;
 import org.mozilla.gecko.home.HomePager.OnUrlOpenListener;
 import org.mozilla.gecko.home.HomePanelsManager;
 import org.mozilla.gecko.home.SearchEngine;
+import org.mozilla.gecko.javaaddons.JavaAddonManager;
 import org.mozilla.gecko.menu.GeckoMenu;
 import org.mozilla.gecko.menu.GeckoMenuItem;
 import org.mozilla.gecko.mozglue.ContextUtils;
 import org.mozilla.gecko.mozglue.ContextUtils.SafeIntent;
 import org.mozilla.gecko.mozglue.RobocopTarget;
 import org.mozilla.gecko.overlays.ui.ShareDialog;
 import org.mozilla.gecko.preferences.ClearOnShutdownPref;
 import org.mozilla.gecko.preferences.GeckoPreferences;
--- a/mobile/android/base/EventDispatcher.java
+++ b/mobile/android/base/EventDispatcher.java
@@ -153,18 +153,24 @@ public final class EventDispatcher {
 
         final String guid = message.optString(GUID, null);
         EventCallback callback = null;
         if (guid != null) {
             callback = new GeckoEventCallback(guid, type);
         }
 
         if (listeners != null) {
-            if (listeners.size() == 0) {
+            if (listeners.isEmpty()) {
                 Log.w(LOGTAG, "No listeners for " + type);
+
+                // There were native listeners, and they're gone.  Dispatch an error rather than
+                // looking for JSON listeners.
+                if (callback != null) {
+                    callback.sendError("No listeners for request");
+                }
             }
             try {
                 for (final NativeEventListener listener : listeners) {
                     listener.handleMessage(type, message, callback);
                 }
             } catch (final NativeJSObject.InvalidPropertyException e) {
                 Log.e(LOGTAG, "Exception occurred while handling " + type, e);
             }
@@ -190,17 +196,17 @@ public final class EventDispatcher {
         //   ...
         try {
             final String type = message.getString("type");
 
             List<GeckoEventListener> listeners;
             synchronized (mGeckoThreadJSONListeners) {
                 listeners = mGeckoThreadJSONListeners.get(type);
             }
-            if (listeners == null || listeners.size() == 0) {
+            if (listeners == null || listeners.isEmpty()) {
                 Log.w(LOGTAG, "No listeners for " + type);
 
                 // If there are no listeners, dispatch an error.
                 if (callback != null) {
                     callback.sendError("No listeners for request");
                 }
                 return;
             }
--- a/mobile/android/base/Makefile.in
+++ b/mobile/android/base/Makefile.in
@@ -100,16 +100,17 @@ java_bundled_libs := $(subst $(NULL) ,:,
 ALL_JARS = \
   constants.jar \
   gecko-R.jar \
   gecko-browser.jar \
   gecko-mozglue.jar \
   gecko-thirdparty.jar \
   gecko-util.jar \
   sync-thirdparty.jar \
+  ../javaaddons/javaaddons-1.0.jar \
   $(NULL)
 
 ifdef MOZ_WEBRTC
 ALL_JARS += webrtc.jar
 endif
 
 ifdef MOZ_ANDROID_SEARCH_ACTIVITY
 ALL_JARS += search-activity.jar
rename from mobile/android/base/JavaAddonManager.java
rename to mobile/android/base/javaaddons/JavaAddonManager.java
--- a/mobile/android/base/JavaAddonManager.java
+++ b/mobile/android/base/javaaddons/JavaAddonManager.java
@@ -1,27 +1,25 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
-package org.mozilla.gecko;
-
-import org.mozilla.gecko.util.GeckoEventListener;
-
-import org.json.JSONException;
-import org.json.JSONObject;
+package org.mozilla.gecko.javaaddons;
 
 import android.content.Context;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.Message;
 import android.util.Log;
-
 import dalvik.system.DexClassLoader;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoEventListener;
 
 import java.io.File;
 import java.lang.reflect.Constructor;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.Map;
 
 /**
@@ -42,17 +40,17 @@ import java.util.Map;
  * The Handler.Callback instances provided (as described above) are invoked with
  * Message objects when the corresponding events are dispatched. The Bundle
  * object attached to the Message will contain the "primitive" values from the
  * JSON of the event. ("primitive" includes bool/int/long/double/String). If
  * the addon callback wishes to synchronously return a value back to the event
  * dispatcher, they can do so by inserting the response string into the bundle
  * under the key "response".
  */
-class JavaAddonManager implements GeckoEventListener {
+public class JavaAddonManager implements GeckoEventListener {
     private static final String LOGTAG = "GeckoJavaAddonManager";
 
     private static JavaAddonManager sInstance;
 
     private final EventDispatcher mDispatcher;
     private final Map<String, Map<String, GeckoEventListener>> mAddonCallbacks;
 
     private Context mApplicationContext;
@@ -64,25 +62,26 @@ class JavaAddonManager implements GeckoE
         return sInstance;
     }
 
     private JavaAddonManager() {
         mDispatcher = EventDispatcher.getInstance();
         mAddonCallbacks = new HashMap<String, Map<String, GeckoEventListener>>();
     }
 
-    void init(Context applicationContext) {
+    public void init(Context applicationContext) {
         if (mApplicationContext != null) {
             // we've already done this registration. don't do it again
             return;
         }
         mApplicationContext = applicationContext;
         mDispatcher.registerGeckoThreadListener(this,
             "Dex:Load",
             "Dex:Unload");
+        JavaAddonManagerV1.getInstance().init(applicationContext);
     }
 
     @Override
     public void handleMessage(String event, JSONObject message) {
         try {
             if (event.equals("Dex:Load")) {
                 String zipFile = message.getString("zipfile");
                 String implClass = message.getString("impl");
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/javaaddons/JavaAddonManagerV1.java
@@ -0,0 +1,260 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.javaaddons;
+
+import android.content.Context;
+import android.support.v4.util.Pair;
+import android.util.Log;
+import dalvik.system.DexClassLoader;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.sync.Utils;
+import org.mozilla.gecko.util.GeckoJarReader;
+import org.mozilla.gecko.util.GeckoRequest;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.javaaddons.JavaAddonInterfaceV1;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.InvocationTargetException;
+import java.util.HashMap;
+import java.util.IdentityHashMap;
+import java.util.Map;
+
+public class JavaAddonManagerV1 implements NativeEventListener {
+    private static final String LOGTAG = "GeckoJavaAddonMgrV1";
+    public static final String MESSAGE_LOAD = "JavaAddonManagerV1:Load";
+    public static final String MESSAGE_UNLOAD = "JavaAddonManagerV1:Unload";
+
+    private static JavaAddonManagerV1 sInstance;
+
+    // Protected by static synchronized.
+    private Context mApplicationContext;
+
+    private final org.mozilla.gecko.EventDispatcher mDispatcher;
+
+    // Protected by synchronized(this).
+    private final Map<String, EventDispatcherImpl> mGUIDToDispatcherMap = new HashMap<>();
+
+    public static synchronized JavaAddonManagerV1 getInstance() {
+        if (sInstance == null) {
+            sInstance = new JavaAddonManagerV1();
+        }
+        return sInstance;
+    }
+
+    private JavaAddonManagerV1() {
+        mDispatcher = org.mozilla.gecko.EventDispatcher.getInstance();
+    }
+
+    public synchronized void init(Context applicationContext) {
+        if (mApplicationContext != null) {
+            // We've already registered; don't register again.
+            return;
+        }
+        mApplicationContext = applicationContext;
+        mDispatcher.registerGeckoThreadListener(this,
+                MESSAGE_LOAD,
+                MESSAGE_UNLOAD);
+    }
+
+    protected String getExtension(String filename) {
+        if (filename == null) {
+            return "";
+        }
+        final int last = filename.lastIndexOf(".");
+        if (last < 0) {
+            return "";
+        }
+        return filename.substring(last);
+    }
+
+    protected synchronized EventDispatcherImpl registerNewInstance(String classname, String filename)
+            throws ClassNotFoundException, InstantiationException, IllegalAccessException, InvocationTargetException, NoSuchMethodException, IOException {
+        Log.d(LOGTAG, "Attempting to instantiate " + classname + "from filename " + filename);
+
+        // It's important to maintain the extension, either .dex, .apk, .jar.
+        final String extension = getExtension(filename);
+        final File dexFile = GeckoJarReader.extractStream(mApplicationContext, filename, mApplicationContext.getCacheDir(), "." + extension);
+        try {
+            if (dexFile == null) {
+                throw new IOException("Could not find file " + filename);
+            }
+            final File tmpDir = mApplicationContext.getDir("dex", 0); // We'd prefer getCodeCacheDir but it's API 21+.
+            final DexClassLoader loader = new DexClassLoader(dexFile.getAbsolutePath(), tmpDir.getAbsolutePath(), null, mApplicationContext.getClassLoader());
+            final Class<?> c = loader.loadClass(classname);
+            final Constructor<?> constructor = c.getDeclaredConstructor(Context.class, JavaAddonInterfaceV1.EventDispatcher.class);
+            final String guid = Utils.generateGuid();
+            final EventDispatcherImpl dispatcher = new EventDispatcherImpl(guid, filename);
+            final Object instance = constructor.newInstance(mApplicationContext, dispatcher);
+            mGUIDToDispatcherMap.put(guid, dispatcher);
+            return dispatcher;
+        } finally {
+            // DexClassLoader writes an optimized version, so we can get rid of our temporary extracted version.
+            if (dexFile != null) {
+                dexFile.delete();
+            }
+        }
+    }
+
+    @Override
+    public synchronized void handleMessage(String event, NativeJSObject message, org.mozilla.gecko.util.EventCallback callback) {
+        try {
+            switch (event) {
+                case MESSAGE_LOAD: {
+                    if (callback == null) {
+                        throw new IllegalArgumentException("callback must not be null");
+                    }
+                    final String classname = message.getString("classname");
+                    final String filename = message.getString("filename");
+                    final EventDispatcherImpl dispatcher = registerNewInstance(classname, filename);
+                    callback.sendSuccess(dispatcher.guid);
+                }
+                break;
+                case MESSAGE_UNLOAD: {
+                    if (callback == null) {
+                        throw new IllegalArgumentException("callback must not be null");
+                    }
+                    final String guid = message.getString("guid");
+                    final EventDispatcherImpl dispatcher = mGUIDToDispatcherMap.remove(guid);
+                    if (dispatcher == null) {
+                        Log.w(LOGTAG, "Attempting to unload addon with unknown associated dispatcher; ignoring.");
+                        callback.sendSuccess(false);
+                    }
+                    dispatcher.unregisterAllEventListeners();
+                    callback.sendSuccess(true);
+                }
+                break;
+            }
+        } catch (Exception e) {
+            Log.e(LOGTAG, "Exception handling message [" + event + "]", e);
+            if (callback != null) {
+                callback.sendError("Exception handling message [" + event + "]: " + e.toString());
+            }
+        }
+    }
+
+    /**
+     * An event dispatcher is tied to a single Java Addon instance.  It serves to prefix all
+     * messages with its unique GUID.
+     * <p/>
+     * Curiously, the dispatcher does not hold a direct reference to its add-on instance.  It will
+     * likely hold indirect instances through its wrapping map, since the instance will probably
+     * register event listeners that hold a reference to itself.  When these listeners are
+     * unregistered, any link will be broken, allowing the instances to be garbage collected.
+     */
+    private class EventDispatcherImpl implements JavaAddonInterfaceV1.EventDispatcher {
+        private final String guid;
+        private final String dexFileName;
+
+        // Protected by synchronized(this).
+        private final Map<JavaAddonInterfaceV1.EventListener, Pair<NativeEventListener, String[]>> mListenerToWrapperMap = new IdentityHashMap<>();
+
+        public EventDispatcherImpl(String guid, String dexFileName) {
+            this.guid = guid;
+            this.dexFileName = dexFileName;
+        }
+
+        protected class ListenerWrapper implements NativeEventListener {
+            private final JavaAddonInterfaceV1.EventListener listener;
+
+            public ListenerWrapper(JavaAddonInterfaceV1.EventListener listener) {
+                this.listener = listener;
+            }
+
+            @Override
+            public void handleMessage(String prefixedEvent, NativeJSObject message, final org.mozilla.gecko.util.EventCallback callback) {
+                if (!prefixedEvent.startsWith(guid + ":")) {
+                    return;
+                }
+                final String event = prefixedEvent.substring(guid.length() + 1); // Skip "guid:".
+                try {
+                    JavaAddonInterfaceV1.EventCallback callbackAdapter = null;
+                    if (callback != null) {
+                        callbackAdapter = new JavaAddonInterfaceV1.EventCallback() {
+                            @Override
+                            public void sendSuccess(Object response) {
+                                callback.sendSuccess(response);
+                            }
+
+                            @Override
+                            public void sendError(Object response) {
+                                callback.sendError(response);
+                            }
+                        };
+                    }
+                    final JSONObject json = new JSONObject(message.toString());
+                    listener.handleMessage(mApplicationContext, event, json, callbackAdapter);
+                } catch (Exception e) {
+                    Log.e(LOGTAG, "Exception handling message [" + prefixedEvent + "]", e);
+                    if (callback != null) {
+                        callback.sendError("Got exception handling message [" + prefixedEvent + "]: " + e.toString());
+                    }
+                }
+            }
+        }
+
+        @Override
+        public synchronized void registerEventListener(final JavaAddonInterfaceV1.EventListener listener, String... events) {
+            if (mListenerToWrapperMap.containsKey(listener)) {
+                Log.e(LOGTAG, "Attempting to register listener which is already registered; ignoring.");
+                return;
+            }
+
+            final NativeEventListener listenerWrapper = new ListenerWrapper(listener);
+
+            final String[] prefixedEvents = new String[events.length];
+            for (int i = 0; i < events.length; i++) {
+                prefixedEvents[i] = this.guid + ":" + events[i];
+            }
+            mDispatcher.registerGeckoThreadListener(listenerWrapper, prefixedEvents);
+            mListenerToWrapperMap.put(listener, new Pair<>(listenerWrapper, prefixedEvents));
+        }
+
+        @Override
+        public synchronized void unregisterEventListener(final JavaAddonInterfaceV1.EventListener listener) {
+            final Pair<NativeEventListener, String[]> pair = mListenerToWrapperMap.remove(listener);
+            if (pair == null) {
+                Log.e(LOGTAG, "Attempting to unregister listener which is not registered; ignoring.");
+                return;
+            }
+            mDispatcher.unregisterGeckoThreadListener(pair.first, pair.second);
+        }
+
+
+        protected synchronized void unregisterAllEventListeners() {
+            // Unregister everything, then forget everything.
+            for (Pair<NativeEventListener, String[]> pair : mListenerToWrapperMap.values()) {
+                 mDispatcher.unregisterGeckoThreadListener(pair.first, pair.second);
+            }
+            mListenerToWrapperMap.clear();
+        }
+
+        @Override
+        public void sendRequestToGecko(final String event, final JSONObject message, final JavaAddonInterfaceV1.RequestCallback callback) {
+            final String prefixedEvent = guid + ":" + event;
+            GeckoAppShell.sendRequestToGecko(new GeckoRequest(prefixedEvent, message) {
+                @Override
+                public void onResponse(NativeJSObject nativeJSObject) {
+                    if (callback == null) {
+                        // Nothing to do.
+                        return;
+                    }
+                    try {
+                        final JSONObject json = new JSONObject(nativeJSObject.toString());
+                        callback.onResponse(GeckoAppShell.getContext(), json);
+                    } catch (JSONException e) {
+                        // No way to report failure.
+                        Log.e(LOGTAG, "Exception handling response to request [" + event + "]:", e);
+                    }
+                }
+            });
+        }
+    }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -352,17 +352,18 @@ gbjar.sources += [
     'home/TopSitesGridItemView.java',
     'home/TopSitesGridView.java',
     'home/TopSitesPanel.java',
     'home/TopSitesThumbnailView.java',
     'home/TransitionAwareCursorLoaderCallbacks.java',
     'home/TwoLinePageRow.java',
     'InputMethods.java',
     'IntentHelper.java',
-    'JavaAddonManager.java',
+    'javaaddons/JavaAddonManager.java',
+    'javaaddons/JavaAddonManagerV1.java',
     'LayoutInterceptor.java',
     'LocaleManager.java',
     'Locales.java',
     'lwt/LightweightTheme.java',
     'lwt/LightweightThemeDrawable.java',
     'MediaCastingBar.java',
     'MemoryMonitor.java',
     'menu/GeckoMenu.java',
@@ -592,16 +593,17 @@ if max_sdk_version >= 11:
     ]
 
 # Selectively include reading list service code.
 if CONFIG['MOZ_ANDROID_READING_LIST_SERVICE']:
     gbjar.sources += reading_list_service_java_files
 
 gbjar.sources += sync_java_files
 gbjar.extra_jars += [
+    OBJDIR + '/../javaaddons/javaaddons-1.0.jar',
     'gecko-R.jar',
     'gecko-mozglue.jar',
     'gecko-thirdparty.jar',
     'gecko-util.jar',
     'sync-thirdparty.jar',
 ]
 
 moz_native_devices_jars = [
--- a/mobile/android/config/proguard/proguard.cfg
+++ b/mobile/android/config/proguard/proguard.cfg
@@ -205,16 +205,25 @@
 }
 -keep class org.mozilla.gecko.AppConstants$Versions {
     *;
 }
 -keep class org.mozilla.gecko.SysInfo {
     *;
 }
 
+# Keep all interfaces that might be dynamically required by Java Addons.
+-keep class org.mozilla.javaaddons.* {
+    *;
+}
+
+-keep class org.mozilla.javaaddons.*$* {
+    *;
+}
+
 # Disable obfuscation because it makes exception stack traces more difficult to read.
 -dontobfuscate
 
 # Suppress warnings about missing descriptor classes.
 #-dontnote **,!ch.boye.**,!org.mozilla.gecko.sync.**
 
 -include "play-services-keeps.cfg"
 
--- a/mobile/android/gradle/app/build.gradle
+++ b/mobile/android/gradle/app/build.gradle
@@ -32,16 +32,17 @@ android {
 
     sourceSets {
         androidTest {
             java {
                 srcDir "${topobjdir}/mobile/android/gradle/app/src/robocop_harness"
                 srcDir "${topobjdir}/mobile/android/gradle/app/src/robocop"
                 srcDir "${topobjdir}/mobile/android/gradle/app/src/background"
                 srcDir "${topobjdir}/mobile/android/gradle/app/src/browser"
+                srcDir "${topobjdir}/mobile/android/gradle/app/src/javaaddons"
             }
         }
     }
 }
 
 dependencies {
     compile project(':base')
     compile project(':omnijar')
new file mode 100644
--- /dev/null
+++ b/mobile/android/javaaddons/Makefile.in
@@ -0,0 +1,10 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+include $(topsrcdir)/config/rules.mk
+
+JAVA_CLASSPATH := $(ANDROID_SDK)/android.jar
+include $(topsrcdir)/config/android-common.mk
+
+libs:: javaaddons-1.0.jar
new file mode 100644
--- /dev/null
+++ b/mobile/android/javaaddons/java/org/mozilla/javaaddons/JavaAddonInterfaceV1.java
@@ -0,0 +1,51 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.javaaddons;
+
+import android.content.Context;
+import org.json.JSONObject;
+
+public interface JavaAddonInterfaceV1 {
+    /**
+     * Callback interface for Gecko requests.
+     * <p/>
+     * For each instance of EventCallback, exactly one of sendResponse, sendError, must be called to prevent observer leaks.
+     * If more than one send* method is called, or if a single send method is called multiple times, an
+     * {@link IllegalStateException} will be thrown.
+     */
+    interface EventCallback {
+        /**
+         * Sends a success response with the given data.
+         *
+         * @param response The response data to send to Gecko. Can be any of the types accepted by
+         *                 JSONObject#put(String, Object).
+         */
+        public void sendSuccess(Object response);
+
+        /**
+         * Sends an error response with the given data.
+         *
+         * @param response The response data to send to Gecko. Can be any of the types accepted by
+         *                 JSONObject#put(String, Object).
+         */
+        public void sendError(Object response);
+    }
+
+    interface EventDispatcher {
+        void registerEventListener(EventListener listener, String... events);
+        void unregisterEventListener(EventListener listener);
+
+        void sendRequestToGecko(String event, JSONObject message, RequestCallback callback);
+    }
+
+    interface EventListener {
+        public void handleMessage(final Context context, final String event, final JSONObject message, final EventCallback callback);
+    }
+
+    interface RequestCallback {
+        void onResponse(final Context context, JSONObject jsonObject);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/javaaddons/moz.build
@@ -0,0 +1,11 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+jar = add_java_jar('javaaddons-1.0')
+jar.sources = [
+    'java/org/mozilla/javaaddons/JavaAddonInterfaceV1.java',
+]
+jar.javac_flags += ['-Xlint:all']
--- a/mobile/android/mach_commands.py
+++ b/mobile/android/mach_commands.py
@@ -121,25 +121,27 @@ class MachCommands(MachCommandBase):
         srcdir('app/src/androidTest/assets', 'mobile/android/tests/browser/robocop/assets')
         objdir('app/src/debug/assets', 'dist/fennec/assets')
         objdir('app/src/debug/jniLibs', 'dist/fennec/lib')
         # Test code.
         srcdir('app/src/robocop_harness/org/mozilla/gecko', 'build/mobile/robocop')
         srcdir('app/src/robocop/org/mozilla/gecko/tests', 'mobile/android/tests/browser/robocop')
         srcdir('app/src/background/org/mozilla/gecko', 'mobile/android/tests/background/junit3/src')
         srcdir('app/src/browser', 'mobile/android/tests/browser/junit3/src')
+        srcdir('app/src/javaaddons', 'mobile/android/tests/javaaddons/src')
         # Test libraries.
         srcdir('app/libs', 'build/mobile/robocop')
 
         srcdir('base/build.gradle', 'mobile/android/gradle/base/build.gradle')
         srcdir('base/lint.xml', 'mobile/android/gradle/base/lint.xml')
         srcdir('base/src/main/AndroidManifest.xml', 'mobile/android/gradle/base/AndroidManifest.xml')
         srcdir('base/src/main/java/org/mozilla/gecko', 'mobile/android/base')
         srcdir('base/src/main/java/org/mozilla/mozstumbler', 'mobile/android/stumbler/java/org/mozilla/mozstumbler')
         srcdir('base/src/main/java/org/mozilla/search', 'mobile/android/search/java/org/mozilla/search')
+        srcdir('base/src/main/java/org/mozilla/javaaddons', 'mobile/android/javaaddons/java/org/mozilla/javaaddons')
         srcdir('base/src/main/res', 'mobile/android/base/resources')
         srcdir('base/src/crashreporter/res', 'mobile/android/base/crashreporter/res')
 
         manifest_path = os.path.join(self.topobjdir, 'mobile', 'android', 'gradle.manifest')
         with FileAvoidWrite(manifest_path) as f:
             m.write(fileobj=f)
 
         self.virtualenv_manager.ensure()
new file mode 100644
--- /dev/null
+++ b/mobile/android/modules/JavaAddonManager.jsm
@@ -0,0 +1,115 @@
+// -*- Mode: js2; tab-width: 2; indent-tabs-mode: nil; js2-basic-offset: 2; js2-skip-preprocessor-directives: t; -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["JavaAddonManager"];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components; /*global Components */
+
+Cu.import("resource://gre/modules/Messaging.jsm"); /*global Messaging */
+Cu.import("resource://gre/modules/Services.jsm"); /*global Services */
+
+function resolveGeckoURI(uri) {
+  if (!uri) {
+    throw new Error("Can't resolve an empty uri");
+  }
+  if (uri.startsWith("chrome://")) {
+    let registry = Cc['@mozilla.org/chrome/chrome-registry;1'].getService(Ci["nsIChromeRegistry"]);
+    return registry.convertChromeURL(Services.io.newURI(uri, null, null)).spec;
+  } else if (uri.startsWith("resource://")) {
+    let handler = Services.io.getProtocolHandler("resource").QueryInterface(Ci.nsIResProtocolHandler);
+    return handler.resolveURI(Services.io.newURI(uri, null, null));
+  }
+  return uri;
+}
+
+/**
+ * A promise-based API
+ */
+let JavaAddonManager = Object.freeze({
+  classInstanceFromFile: function(classname, filename) {
+    if (!classname) {
+      throw new Error("classname cannot be null");
+    }
+    if (!filename) {
+      throw new Error("filename cannot be null");
+    }
+    return Messaging.sendRequestForResult({
+      type: "JavaAddonManagerV1:Load",
+      classname: classname,
+      filename: resolveGeckoURI(filename)
+    })
+      .then((guid) => {
+        if (!guid) {
+          throw new Error("Internal error: guid should not be null");
+        }
+        return new JavaAddonV1({classname: classname, guid: guid});
+      });
+  }
+});
+
+function JavaAddonV1(options = {}) {
+  if (!(this instanceof JavaAddonV1)) {
+    return new JavaAddonV1(options);
+  }
+  if (!options.classname) {
+    throw new Error("options.classname cannot be null");
+  }
+  if (!options.guid) {
+    throw new Error("options.guid cannot be null");
+  }
+  this._classname = options.classname;
+  this._guid = options.guid;
+  this._loaded = true;
+  this._listeners = {};
+}
+
+JavaAddonV1.prototype = Object.freeze({
+  unload: function() {
+    if (!this._loaded) {
+      return;
+    }
+
+    Messaging.sendRequestForResult({
+      type: "JavaAddonManagerV1:Unload",
+      guid: this._guid
+    })
+      .then(() => {
+        this._loaded = false;
+        for (let listener of this._listeners) {
+          // If we use this.removeListener, we prefix twice.
+          Messaging.removeListener(listener);
+        }
+        this._listeners = {};
+      });
+  },
+
+  _prefix: function(message) {
+    let newMessage = Cu.cloneInto(message, {}, { cloneFunctions: false });
+    newMessage.type = this._guid + ":" + message.type;
+    return newMessage;
+  },
+
+  sendRequest: function(message) {
+    return Messaging.sendRequest(this._prefix(message));
+  },
+
+  sendRequestForResult: function(message) {
+    return Messaging.sendRequestForResult(this._prefix(message));
+  },
+
+  addListener: function(listener, message) {
+    let prefixedMessage = this._guid + ":" + message;
+    this._listeners[prefixedMessage] = listener;
+    return Messaging.addListener(listener, prefixedMessage);
+  },
+
+  removeListener: function(message) {
+    let prefixedMessage = this._guid + ":" + message;
+    delete this._listeners[prefixedMessage];
+    return Messaging.removeListener(prefixedMessage);
+  }
+});
--- a/mobile/android/modules/moz.build
+++ b/mobile/android/modules/moz.build
@@ -9,16 +9,17 @@ EXTRA_JS_MODULES += [
     'AndroidLog.jsm',
     'ContactService.jsm',
     'dbg-browser-actors.js',
     'DelayedInit.jsm',
     'DownloadNotifications.jsm',
     'HelperApps.jsm',
     'Home.jsm',
     'HomeProvider.jsm',
+    'JavaAddonManager.jsm',
     'JNI.jsm',
     'LightweightThemeConsumer.jsm',
     'MatchstickApp.jsm',
     'MediaPlayerApp.jsm',
     'Messaging.jsm',
     'NetErrorHelper.jsm',
     'Notifications.jsm',
     'OrderedBroadcast.jsm',
--- a/mobile/android/moz.build
+++ b/mobile/android/moz.build
@@ -10,16 +10,17 @@ DIRS += [
     '../locales',
     'locales',
 ]
 
 if CONFIG['MOZ_ANDROID_MLS_STUMBLER']:
     DIRS += ['stumbler']
 
 DIRS += [
+    'javaaddons', # Must be built before base.
     'base',
     'chrome',
     'components',
     'modules',
     'themes/core',
     'app',
     'fonts',
     'geckoview_library',
--- a/mobile/android/tests/browser/robocop/robocop.ini
+++ b/mobile/android/tests/browser/robocop/robocop.ini
@@ -118,16 +118,17 @@ skip-if = android_version == "10" || and
 # disabled on 4.3, bug 1158384
 skip-if = android_version == "18"
 [testDebuggerServer.java]
 [testDeviceSearchEngine.java]
 [testFilePicker.java]
 [testHistoryService.java]
 # disabled on 4.3, bug 1116036
 skip-if = android_version == "18"
+[testJavaAddons.java]
 [testJNI.java]
 # [testMozPay.java] # see bug 945675
 [testMigrateUI.java]
 [testNetworkManager.java]
 [testOfflinePage.java]
 [testOrderedBroadcast.java]
 [testOSLocale.java]
 # disabled on 2.3 and 4.3: Bug 1124494
--- a/mobile/android/tests/browser/robocop/roboextender/Makefile.in
+++ b/mobile/android/tests/browser/robocop/roboextender/Makefile.in
@@ -14,11 +14,12 @@ TEST_FILES = \
   install.rdf \
   chrome.manifest \
   $(NULL)
 TEST_DEST = $(TEST_EXTENSIONS_DIR)/roboextender@mozilla.org/
 INSTALL_TARGETS += TEST
 
 include $(topsrcdir)/config/rules.mk
 
-libs:: $(_TEST_FILES)
+tools:: $(_TEST_FILES)
 	$(MKDIR) -p $(TEST_EXTENSIONS_DIR)/roboextender@mozilla.org/base
 	-cp $(TESTPATH)/base/* $(TEST_EXTENSIONS_DIR)/roboextender@mozilla.org/base
+	-cp $(DEPTH)/mobile/android/tests/javaaddons/javaaddons-test.apk $(TEST_EXTENSIONS_DIR)/roboextender@mozilla.org/base
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/browser/robocop/testJavaAddons.java
@@ -0,0 +1,13 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko.tests;
+
+
+
+public class testJavaAddons extends JavascriptTest {
+    public testJavaAddons() {
+        super("testJavaAddons.js");
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/browser/robocop/testJavaAddons.js
@@ -0,0 +1,97 @@
+// -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this file,
+ * You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+let Log = Cu.import("resource://gre/modules/AndroidLog.jsm", {}).AndroidLog.bind("TestJavaAddons");
+Cu.import("resource://gre/modules/JavaAddonManager.jsm"); /*global JavaAddonManager */
+Cu.import("resource://gre/modules/Promise.jsm"); /*global Promise */
+Cu.import("resource://gre/modules/Services.jsm"); /*global Services */
+Cu.import("resource://gre/modules/Messaging.jsm"); /*global Messaging */
+
+const DEX_FILE = "chrome://roboextender/content/javaaddons-test.apk";
+const CLASS = "org.mozilla.javaaddons.test.JavaAddonV1";
+
+const MESSAGE = "JavaAddon:V1";
+
+add_task(function testFailureCases() {
+  do_print("Loading Java Addon from non-existent class.");
+  let gotError1 = yield JavaAddonManager.classInstanceFromFile(CLASS + "GARBAGE", DEX_FILE)
+    .then((result) => false)
+    .catch((error) => true);
+  do_check_eq(gotError1, true);
+
+  do_print("Loading Java Addon from non-existent DEX file.");
+  let gotError2 = yield JavaAddonManager.classInstanceFromFile(CLASS, DEX_FILE + "GARBAGE")
+    .then((result) => false)
+    .catch((error) => true);
+  do_check_eq(gotError2, true);
+});
+
+// Make a request to a dynamically loaded Java Addon; wait for a response.
+// Then expect the add-on to make a request; respond.
+// Then expect the add-on to make a second request; use it to verify the response to the first request.
+add_task(function testJavaAddonV1() {
+  do_print("Loading Java Addon from: " + DEX_FILE);
+
+  let javaAddon = yield JavaAddonManager.classInstanceFromFile(CLASS, DEX_FILE);
+  do_check_neq(javaAddon, null);
+  do_check_neq(javaAddon._guid, null);
+  do_check_eq(javaAddon._classname, CLASS);
+  do_check_eq(javaAddon._loaded, true);
+
+  let messagePromise = Promise.defer();
+  var count = 0;
+  function listener(data) {
+    do_print("Got request initiated from Java Addon: " + data + ", " + typeof(data) + ", " + JSON.stringify(data));
+    count += 1;
+    messagePromise.resolve(); // It's okay to resolve before returning: we'll wait on the verification promise no matter what.
+    return {
+      outputStringKey: "inputStringKey=" + data.inputStringKey,
+      outputIntKey: data.inputIntKey - 1
+    };
+  }
+  javaAddon.addListener(listener, "JavaAddon:V1:Request");
+
+  let verifierPromise = Promise.defer();
+  function verifier(data) {
+    do_print("Got verification request initiated from Java Addon: " + data + ", " + typeof(data) + ", " + JSON.stringify(data));
+    // These values are from the test Java Addon, after being processed by the :Request listener above.
+    do_check_eq(data.outputStringKey, "inputStringKey=raw");
+    do_check_eq(data.outputIntKey, 2);
+    verifierPromise.resolve();
+    return {};
+  }
+  javaAddon.addListener(verifier, "JavaAddon:V1:VerificationRequest");
+
+  let message = {type: MESSAGE, inputStringKey: "test", inputIntKey: 5};
+  do_print("Sending request to Java Addon: " + JSON.stringify(message));
+  let output = yield javaAddon.sendRequestForResult(message);
+
+  do_print("Got response from Java Addon: " + output + ", " + typeof(output) + ", " + JSON.stringify(output));
+  do_check_eq(output.outputStringKey, "inputStringKey=test");
+  do_check_eq(output.outputIntKey, 6);
+
+  // We don't worry about timing out: the harness will (very much later)
+  // kill us if we don't see the expected messages.
+
+  do_print("Waiting for request initiated from Java Addon.");
+  yield messagePromise.promise;
+  do_check_eq(count, 1);
+
+  do_print("Send request for result 2 for request initiated from Java Addon.");
+
+  // The JavaAddon should have removed its listener, so we shouldn't get a response and count should stay the same.
+  let gotError = yield javaAddon.sendRequestForResult(message)
+    .then((result) => false)
+    .catch((error) => true);
+  do_check_eq(gotError, true);
+  do_check_eq(count, 1);
+
+  do_print("Waiting for verification request initiated from Java Addon.");
+  yield verifierPromise.promise;
+});
+
+run_next_test();
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/javaaddons/AndroidManifest.xml.in
@@ -0,0 +1,14 @@
+#filter substitution
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="org.mozilla.javaaddons.test"
+    android:versionCode="1"
+    android:versionName="1.0" >
+
+    <uses-sdk android:minSdkVersion="@MOZ_ANDROID_MIN_SDK_VERSION@"
+#ifdef MOZ_ANDROID_MAX_SDK_VERSION
+              android:maxSdkVersion="@MOZ_ANDROID_MAX_SDK_VERSION@"
+#endif
+              android:targetSdkVersion="@ANDROID_TARGET_SDK@"/>
+
+</manifest>
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/javaaddons/Makefile.in
@@ -0,0 +1,16 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+ANDROID_APK_NAME := javaaddons-test
+
+PP_TARGETS += manifest
+manifest := $(srcdir)/AndroidManifest.xml.in
+manifest_TARGET := export
+ANDROID_MANIFEST_FILE := $(CURDIR)/AndroidManifest.xml
+
+ANDROID_EXTRA_JARS := javaaddons-test.jar
+
+tools libs:: $(ANDROID_APK_NAME).apk
+
+include $(topsrcdir)/config/rules.mk
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/javaaddons/moz.build
@@ -0,0 +1,16 @@
+# -*- Mode: python; c-basic-offset: 4; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+jar = add_java_jar('javaaddons-test')
+jar.extra_jars += [
+    TOPOBJDIR + '/mobile/android/javaaddons/javaaddons-1.0.jar',
+]
+jar.javac_flags += ['-Xlint:all']
+jar.sources += [
+    'src/org/mozilla/javaaddons/test/ClassWithNoRecognizedConstructors.java',
+    'src/org/mozilla/javaaddons/test/JavaAddonV0.java',
+    'src/org/mozilla/javaaddons/test/JavaAddonV1.java',
+]
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/javaaddons/res/values/strings.xml
@@ -0,0 +1,3 @@
+<resources>
+    <string name="app_name">org.mozilla.javaaddons.test</string>
+</resources>
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/javaaddons/src/org/mozilla/javaaddons/test/ClassWithNoRecognizedConstructors.java
@@ -0,0 +1,11 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.javaaddons.test;
+
+public class ClassWithNoRecognizedConstructors {
+    public ClassWithNoRecognizedConstructors(int a, String b, boolean c) {
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/javaaddons/src/org/mozilla/javaaddons/test/JavaAddonV0.java
@@ -0,0 +1,24 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.javaaddons.test;
+
+import android.os.Handler;
+import android.os.Message;
+import android.util.Log;
+
+import java.util.Map;
+
+public class JavaAddonV0 implements Handler.Callback {
+    public JavaAddonV0(Map<String, Handler.Callback> callbacks) {
+        callbacks.put("JavaAddon:V0", this);
+    }
+
+    @Override
+    public boolean handleMessage(Message message) {
+        Log.i("JavaAddon", "handleMessage " + message.toString());
+        return true;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/tests/javaaddons/src/org/mozilla/javaaddons/test/JavaAddonV1.java
@@ -0,0 +1,59 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.javaaddons.test;
+
+import android.content.Context;
+import android.util.Log;
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.javaaddons.JavaAddonInterfaceV1.EventCallback;
+import org.mozilla.javaaddons.JavaAddonInterfaceV1.EventDispatcher;
+import org.mozilla.javaaddons.JavaAddonInterfaceV1.EventListener;
+import org.mozilla.javaaddons.JavaAddonInterfaceV1.RequestCallback;
+
+public class JavaAddonV1 implements EventListener, RequestCallback {
+    protected final EventDispatcher mDispatcher;
+
+    public JavaAddonV1(Context context, EventDispatcher dispatcher) {
+        mDispatcher = dispatcher;
+        mDispatcher.registerEventListener(this, "JavaAddon:V1");
+    }
+
+    @Override
+    public void handleMessage(Context context, String event, JSONObject message, EventCallback callback) {
+        Log.i("JavaAddon", "handleMessage: " + event + ", " + message.toString());
+        final JSONObject output = new JSONObject();
+        try {
+            output.put("outputStringKey", "inputStringKey=" + message.getString("inputStringKey"));
+            output.put("outputIntKey", 1 + message.getInt("inputIntKey"));
+        } catch (JSONException e) {
+            // Should never happen; ignore.
+        }
+        // Respond.
+        if (callback != null) {
+            callback.sendSuccess(output);
+        }
+
+        // And send an independent Gecko event.
+        final JSONObject input = new JSONObject();
+        try {
+            input.put("inputStringKey", "raw");
+            input.put("inputIntKey", 3);
+        } catch (JSONException e) {
+            // Should never happen; ignore.
+        }
+        mDispatcher.sendRequestToGecko("JavaAddon:V1:Request", input, this);
+    }
+
+    @Override
+    public void onResponse(Context context, JSONObject jsonObject) {
+        Log.i("JavaAddon", "onResponse: " + jsonObject.toString());
+        // Unregister event listener, so that the JavaScript side can send a test message and
+        // check it is not handled.
+        mDispatcher.unregisterEventListener(this);
+        mDispatcher.sendRequestToGecko("JavaAddon:V1:VerificationRequest", jsonObject, null);
+    }
+}
--- a/mobile/android/tests/moz.build
+++ b/mobile/android/tests/moz.build
@@ -2,11 +2,13 @@
 # vim: set filetype=python:
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 TEST_DIRS += [
     'background',
     'browser',
+    'javaaddons', # Must be built before browser/robocop/roboextender.
+                  # This is enforced in config/recurse.mk.
 ]
 
 ANDROID_INSTRUMENTATION_MANIFESTS += ['browser/robocop/robocop.ini']