Bug 1200445 - Expose android native apps trough the navigator.mozApps api r=snorp,ferjm
☠☠ backed out by c8534ce6a233 ☠ ☠
authorFabrice Desré <fabrice@mozilla.com>
Mon, 14 Sep 2015 09:30:28 -0700
changeset 295051 fd2a42de520373740a663309d48e10881ed40a5b
parent 295049 6da01e6fcdf0846e4969e499e3823528f576be22
child 295052 6a6fff62b5e3f72056300557eb1f17f219d8dfa8
push id5245
push userraliiev@mozilla.com
push dateThu, 29 Oct 2015 11:30:51 +0000
treeherdermozilla-beta@dac831dc1bd0 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssnorp, ferjm
bugs1200445
milestone43.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1200445 - Expose android native apps trough the navigator.mozApps api r=snorp,ferjm
dom/apps/AndroidUtils.jsm
dom/apps/AppsUtils.jsm
dom/apps/Webapps.jsm
dom/apps/moz.build
mobile/android/b2gdroid/app/Makefile.in
mobile/android/b2gdroid/app/src/main/java/org/mozilla/b2gdroid/Apps.java
mobile/android/b2gdroid/app/src/main/java/org/mozilla/b2gdroid/Launcher.java
new file mode 100644
--- /dev/null
+++ b/dom/apps/AndroidUtils.jsm
@@ -0,0 +1,123 @@
+/* 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 { interfaces: Ci, utils: Cu } = Components;
+
+this.EXPORTED_SYMBOLS = ["AndroidUtils"];
+
+Cu.import("resource://gre/modules/AppsUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(this, "Messaging",
+                                  "resource://gre/modules/Messaging.jsm");
+
+let appsRegistry = null;
+
+function debug() {
+  //dump("-*- AndroidUtils " + Array.slice(arguments) + "\n");
+}
+
+// Helper functions to manage Android native apps. We keep them in the
+// registry with a `kind` equals to "android-native" and we also store
+// the package name and class name in the registry.
+// Communication with the android side happens through json messages.
+
+this.AndroidUtils = {
+  init: function(aRegistry) {
+    appsRegistry = aRegistry;
+    Services.obs.addObserver(this, "Android:Apps:Installed", false);
+    Services.obs.addObserver(this, "Android:Apps:Uninstalled", false);
+  },
+
+  uninit: function() {
+    Services.obs.removeObserver(this, "Android:Apps:Installed");
+    Services.obs.removeObserver(this, "Android:Apps:Uninstalled");
+  },
+
+  getOriginAndManifestURL: function(aPackageName) {
+    let origin = "android://" + aPackageName.toLowerCase();
+    let manifestURL = origin + "/manifest.webapp";
+    return [origin, manifestURL];
+  },
+
+  getPackageAndClassFromManifestURL: function(aManifestURL) {
+    debug("getPackageAndClassFromManifestURL " + aManifestURL);
+    let app = appsRegistry.getAppByManifestURL(aManifestURL);
+    if (!app) {
+      debug("No app for " + aManifestURL);
+      return [];
+    }
+    return [app.android_packagename, app.android_classname];
+  },
+
+  buildAndroidAppData: function(aApp) {
+    // Use the package and class name to get a unique origin.
+    // We put the version with the normal case as part of the manifest url.
+    let [origin, manifestURL] =
+      this.getOriginAndManifestURL(aApp.packagename);
+    // TODO: Bug 1204557 to improve the icons support.
+    let manifest = {
+      name: aApp.name,
+      icons: { "96": aApp.icon }
+    }
+    debug("Origin is " + origin);
+    let appData = {
+      app: {
+        installOrigin: origin,
+        origin: origin,
+        manifest: manifest,
+        manifestURL: manifestURL,
+        manifestHash: AppsUtils.computeHash(JSON.stringify(manifest)),
+        appStatus: Ci.nsIPrincipal.APP_STATUS_INSTALLED,
+        removable: aApp.removable,
+        android_packagename: aApp.packagename,
+        android_classname: aApp.classname
+      },
+      isBrowser: false,
+      isPackage: false
+    };
+
+    return appData;
+  },
+
+  installAndroidApps: function() {
+    return Messaging.sendRequestForResult({ type: "Apps:GetList" }).then(
+      aApps => {
+        debug("Got " + aApps.apps.length + " android apps.");
+        let promises = [];
+        aApps.apps.forEach(app => {
+          debug("App is " + app.name + " removable? " + app.removable);
+          let p = new Promise((aResolveInstall, aRejectInstall) => {
+            let appData = this.buildAndroidAppData(app);
+            appsRegistry.confirmInstall(appData, null, aResolveInstall);
+          });
+          promises.push(p);
+        });
+
+        // Wait for all apps to be installed.
+        return Promise.all(promises);
+      }
+    ).then(appsRegistry._saveApps);
+  },
+
+  observe: function(aSubject, aTopic, aData) {
+    let data;
+    try {
+      data = JSON.parse(aData);
+    } catch(e) {
+      debug(e);
+      return;
+    }
+
+    if (aTopic == "Android:Apps:Installed") {
+      let appData = this.buildAndroidAppData(data);
+      appsRegistry.confirmInstall(appData);
+    } else if (aTopic == "Android:Apps:Uninstalled") {
+      let [origin, manifestURL] =
+        this.getOriginAndManifestURL(data.packagename);
+      appsRegistry.uninstall(manifestURL);
+    }
+  },
+}
--- a/dom/apps/AppsUtils.jsm
+++ b/dom/apps/AppsUtils.jsm
@@ -125,16 +125,20 @@ function _setAppProperties(aObj, aApp) {
   aObj.storeId = aApp.storeId || "";
   aObj.storeVersion = aApp.storeVersion || 0;
   aObj.role = aApp.role || "";
   aObj.redirects = aApp.redirects;
   aObj.widgetPages = aApp.widgetPages || [];
   aObj.kind = aApp.kind;
   aObj.enabled = aApp.enabled !== undefined ? aApp.enabled : true;
   aObj.sideloaded = aApp.sideloaded;
+#ifdef MOZ_B2GDROID
+  aObj.android_packagename = aApp.android_packagename;
+  aObj.android_classname = aApp.android_classname;
+#endif
 }
 
 this.AppsUtils = {
   // Clones a app, without the manifest.
   cloneAppObject: function(aApp) {
     let obj = {};
     _setAppProperties(obj, aApp);
     return obj;
--- a/dom/apps/Webapps.jsm
+++ b/dom/apps/Webapps.jsm
@@ -86,16 +86,19 @@ XPCOMUtils.defineLazyModuleGetter(this, 
                                   "resource://gre/modules/Langpacks.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "ImportExport",
                                   "resource://gre/modules/ImportExport.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AppConstants",
                                   "resource://gre/modules/AppConstants.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "Messaging",
+                                  "resource://gre/modules/Messaging.jsm");
+
 #ifdef MOZ_WIDGET_GONK
 XPCOMUtils.defineLazyGetter(this, "libcutils", function() {
   Cu.import("resource://gre/modules/systemlibs.js");
   return libcutils;
 });
 #endif
 
 #ifdef MOZ_WIDGET_ANDROID
@@ -190,16 +193,17 @@ XPCOMUtils.defineLazyGetter(this, "permM
 // store even by error.
 const STORE_ID_PENDING_PREFIX = "#unknownID#";
 
 this.DOMApplicationRegistry = {
   // pseudo-constants for the different application kinds.
   get kPackaged()       "packaged",
   get kHosted()         "hosted",
   get kHostedAppcache() "hosted-appcache",
+  get kAndroid()        "android-native",
 
   // Path to the webapps.json file where we store the registry data.
   appsFile: null,
   webapps: { },
   allAppsLaunchable: false,
   _updateHandlers: [ ],
   _pendingUninstalls: {},
   _contentActions: new Map(),
@@ -254,16 +258,21 @@ this.DOMApplicationRegistry = {
 
     this.loadAndUpdateApps();
 
     Langpacks.registerRegistryFunctions(MessageBroadcaster.broadcastMessage.bind(MessageBroadcaster),
                                         this._appIdForManifestURL.bind(this),
                                         this.getFullAppByManifestURL.bind(this));
 
     MessageBroadcaster.init(this.getAppByManifestURL);
+
+    if (AppConstants.MOZ_B2GDROID) {
+      Cu.import("resource://gre/modules/AndroidUtils.jsm");
+      AndroidUtils.init(this);
+    }
   },
 
   // loads the current registry, that could be empty on first run.
   loadCurrentRegistry: function() {
     return AppsUtils.loadJSONAsync(this.appsFile).then((aData) => {
       if (!aData) {
         return;
       }
@@ -459,17 +468,19 @@ this.DOMApplicationRegistry = {
     // Create or Update the DataStore for this app
     let results = yield this._readManifests([{ id: aId }]);
     let app = this.webapps[aId];
     this.updateDataStore(app.localId, app.origin, app.manifestURL,
                          results[0].manifest, app.appStatus);
   }),
 
   appKind: function(aApp, aManifest) {
-    if (aApp.origin.startsWith("app://")) {
+    if (aApp.origin.startsWith("android://")) {
+      return this.kAndroid;
+    } if (aApp.origin.startsWith("app://")) {
       return this.kPackaged;
     } else {
       // Hosted apps, can be appcached or not.
       let kind = this.kHosted;
       if (aManifest.appcache_path) {
         kind = this.kHostedAppcache;
       }
       return kind;
@@ -517,17 +528,20 @@ this.DOMApplicationRegistry = {
           appcache_path: fullAppcachePath
         });
       }
     });
   },
 
   // Installs a 3rd party app.
   installPreinstalledApp: function installPreinstalledApp(aId) {
-#ifdef MOZ_WIDGET_GONK
+    if (!AppConstants.MOZ_B2GDROID && AppConstants.platform !== "gonk") {
+      return false;
+    }
+
     // In some cases, the app might be already installed under a different ID but
     // with the same manifestURL. In that case, the only content of the webapp will
     // be the id of the old version, which is the one we'll keep.
     let destId  = this.webapps[aId].oldId || aId;
     // We don't need the oldId anymore
     if (destId !== aId) {
       delete this.webapps[aId];
     }
@@ -620,17 +634,16 @@ this.DOMApplicationRegistry = {
       // If we are unable to extract the manifest, cleanup and remove this app.
       debug("Cleaning up: " + e);
       destDir.remove(true);
       delete this.webapps[destId];
     } finally {
       zipReader.close();
     }
     return isPreinstalled;
-#endif
   },
 
   // For hosted apps, uninstall an app served from http:// if we have
   // one installed from the same url with an https:// scheme.
   removeIfHttpsDuplicate: function(aId) {
 #ifdef MOZ_WIDGET_GONK
     let app = this.webapps[aId];
     if (!app || !app.origin.startsWith("http://")) {
@@ -787,29 +800,33 @@ this.DOMApplicationRegistry = {
             debug("Skipping app migration.");
           }
         }
 
         if (AppConstants.MOZ_B2GDROID || AppConstants.MOZ_B2G) {
           yield this.installSystemApps();
         }
 
+        if (AppConstants.MOZ_B2GDROID) {
+          yield AndroidUtils.installAndroidApps();
+        }
+
         // At first run, install preloaded apps and set up their permissions.
         for (let id in this.webapps) {
           let isPreinstalled = this.installPreinstalledApp(id);
           this.removeIfHttpsDuplicate(id);
           if (!this.webapps[id]) {
             continue;
           }
           this.updateOfflineCacheForApp(id);
           this.updatePermissionsForApp(id, isPreinstalled);
         }
         // Need to update the persisted list of apps since
         // installPreinstalledApp() removes the ones failing to install.
-        this._saveApps();
+        yield this._saveApps();
 
         Services.prefs.setBoolPref("dom.apps.reset-permissions", true);
       }
 
       // DataStores must be initialized at startup.
       for (let id in this.webapps) {
         yield this.updateDataStoreForApp(id);
       }
@@ -1190,16 +1207,19 @@ this.DOMApplicationRegistry = {
   observe: function(aSubject, aTopic, aData) {
     if (aTopic == "xpcom-shutdown") {
       this.messages.forEach((function(msgName) {
         ppmm.removeMessageListener(msgName, this);
       }).bind(this));
       Services.obs.removeObserver(this, "xpcom-shutdown");
       cpmm = null;
       ppmm = null;
+      if (AppConstants.MOZ_B2GDROID) {
+        AndroidUtils.uninit();
+      }
     } else if (aTopic == "memory-pressure") {
       // Clear the manifest cache on memory pressure.
       this._manifestCache = {};
     }
   },
 
   formatMessage: function(aData) {
     let msg = aData;
@@ -1299,32 +1319,32 @@ this.DOMApplicationRegistry = {
       return;
     }
 
     // For all the rest (asynchronous), we wait till the registry is ready
     // before processing the message.
     this.registryReady.then( () => {
       switch (aMessage.name) {
         case "Webapps:Install": {
-#ifdef MOZ_WIDGET_ANDROID
-          Services.obs.notifyObservers(mm, "webapps-runtime-install", JSON.stringify(msg));
-#else
-          this.doInstall(msg, mm);
-#endif
+          if (AppConstants.platform == "android" && !AppConstants.MOZ_B2GDROID) {
+            Services.obs.notifyObservers(mm, "webapps-runtime-install", JSON.stringify(msg));
+          } else {
+            this.doInstall(msg, mm);
+          }
           break;
         }
         case "Webapps:GetSelf":
           this.getSelf(msg, mm);
           break;
         case "Webapps:Uninstall":
-#ifdef MOZ_WIDGET_ANDROID
-          Services.obs.notifyObservers(mm, "webapps-runtime-uninstall", JSON.stringify(msg));
-#else
-          this.doUninstall(msg, mm);
-#endif
+          if (AppConstants.platform == "android" && !AppConstants.MOZ_B2GDROID) {
+            Services.obs.notifyObservers(mm, "webapps-runtime-uninstall", JSON.stringify(msg));
+          } else {
+            this.doUninstall(msg, mm);
+          }
           break;
         case "Webapps:Launch":
           this.doLaunch(msg, mm);
           break;
         case "Webapps:LocationChange":
           this.onLocationChange(msg.oid);
           break;
         case "Webapps:CheckInstalled":
@@ -1332,21 +1352,21 @@ this.DOMApplicationRegistry = {
           break;
         case "Webapps:GetInstalled":
           this.getInstalled(msg, mm);
           break;
         case "Webapps:GetNotInstalled":
           this.getNotInstalled(msg, mm);
           break;
         case "Webapps:InstallPackage": {
-#ifdef MOZ_WIDGET_ANDROID
-          Services.obs.notifyObservers(mm, "webapps-runtime-install-package", JSON.stringify(msg));
-#else
-          this.doInstallPackage(msg, mm);
-#endif
+          if (AppConstants.platform == "android" && !AppConstants.MOZ_B2GDROID) {
+            Services.obs.notifyObservers(mm, "webapps-runtime-install-package", JSON.stringify(msg));
+          } else {
+            this.doInstallPackage(msg, mm);
+          }
           break;
         }
         case "Webapps:Download":
           this.startDownload(msg.manifestURL);
           break;
         case "Webapps:CancelDownload":
           this.cancelDownload(msg.manifestURL);
           break;
@@ -1597,16 +1617,29 @@ this.DOMApplicationRegistry = {
 
     // Fire an error when trying to launch an app that is not
     // yet fully installed.
     if (app.installState == "pending") {
       aOnFailure("PENDING_APP_NOT_LAUNCHABLE");
       return;
     }
 
+    // Delegate native android apps launch.
+    if (this.kAndroid == app.kind) {
+      debug("Launching android app " + app.origin);
+      let [packageName, className] =
+        AndroidUtils.getPackageAndClassFromManifestURL(aManifestURL);
+      debug("  " + packageName + " " + className);
+      Messaging.sendRequest({ type: "Apps:Launch",
+                              packagename: packageName,
+                              classname: className });
+      aOnSuccess();
+      return;
+    }
+
     // We have to clone the app object as nsIDOMApplication objects are
     // stringified as an empty object. (see bug 830376)
     let appClone = AppsUtils.cloneAppObject(app);
     appClone.startPoint = aStartPoint;
     appClone.timestamp = aTimeStamp;
     Services.obs.notifyObservers(null, "webapps-launch", JSON.stringify(appClone));
     aOnSuccess();
   },
@@ -2041,18 +2074,19 @@ this.DOMApplicationRegistry = {
 
     // We may be able to remove this when Bug 839071 is fixed.
     if (app.downloading) {
       sendError("APP_IS_DOWNLOADING");
       return;
     }
 
     // If the app is packaged and its manifestURL has an app:// scheme,
-    // then we can't have an update.
-    if (app.kind == this.kPackaged && app.manifestURL.startsWith("app://")) {
+    // or if it's a native Android app then we can't have an update.
+    if (app.kind == this.kAndroid ||
+        (app.kind == this.kPackaged && app.manifestURL.startsWith("app://"))) {
       sendError("NOT_UPDATABLE");
       return;
     }
 
     // For non-removable hosted apps that lives in the core apps dir we
     // only check the appcache because we can't modify the manifest even
     // if it has changed.
     let onlyCheckAppCache = false;
@@ -2774,18 +2808,20 @@ this.DOMApplicationRegistry = {
         this.revertDownloadPackage(id, oldApp, newApp, false, ex);
       }
     }
   }),
 
   _setupApp: function(aData, aId) {
     let app = aData.app;
 
-    // app can be uninstalled
-    app.removable = true;
+    // app can be uninstalled by default.
+    if (app.removable === undefined) {
+      app.removable = true;
+    }
 
     if (aData.isPackage) {
       // Override the origin with the correct id.
       app.origin = "app://" + aId;
     }
 
     app.id = aId;
     app.installTime = Date.now();
@@ -2808,17 +2844,18 @@ this.DOMApplicationRegistry = {
       appObject.downloadSize = 0;
       appObject.readyToApplyDownload = false;
     } else if (appObject.kind == this.kPackaged) {
       appObject.installState = "pending";
       appObject.downloadAvailable = true;
       appObject.downloading = true;
       appObject.downloadSize = aLocaleManifest.size;
       appObject.readyToApplyDownload = false;
-    } else if (appObject.kind == this.kHosted) {
+    } else if (appObject.kind == this.kHosted ||
+               appObject.kind == this.kAndroid) {
       appObject.installState = "installed";
       appObject.downloadAvailable = false;
       appObject.downloading = false;
       appObject.readyToApplyDownload = false;
     } else {
       debug("Unknown app kind: " + appObject.kind);
       throw Error("Unknown app kind: " + appObject.kind);
     }
@@ -2864,18 +2901,16 @@ this.DOMApplicationRegistry = {
 
     app.csp = aManifest.csp || "";
 
     let aLocaleManifest = new ManifestHelper(aManifest, app.origin, app.manifestURL);
     this._saveWidgetsFullPath(aLocaleManifest, app);
 
     app.appStatus = AppsUtils.getAppManifestStatus(aManifest);
 
-    app.removable = true;
-
     // Reuse the app ID if the scheme is "app".
     let uri = Services.io.newURI(app.origin, null, null);
     if (uri.scheme == "app") {
       app.id = uri.host;
     } else {
       app.id = this.makeAppId();
     }
 
@@ -2955,16 +2990,17 @@ this.DOMApplicationRegistry = {
       new ManifestHelper(jsonManifest, app.origin, app.manifestURL);
 
     // Set the application kind.
     app.kind = this.appKind(app, manifest);
 
     let appObject = this._cloneApp(aData, app, manifest, jsonManifest, id, localId);
 
     this.webapps[id] = appObject;
+    this._manifestCache[id] = jsonManifest;
 
     // For package apps, the permissions are not in the mini-manifest, so
     // don't update the permissions yet.
     if (!aData.isPackage) {
       if (supportUseCurrentProfile()) {
         try {
           if (Services.prefs.getBoolPref("dom.apps.developer_mode")) {
             this.webapps[id].appStatus =
@@ -3029,16 +3065,20 @@ this.DOMApplicationRegistry = {
     // corresponding DOMRequest.onsuccess event as soon as the app is properly
     // saved in the registry.
     yield this._saveApps();
 
     aData.isPackage ? appObject.updateManifest = jsonManifest :
                       appObject.manifest = jsonManifest;
     MessageBroadcaster.broadcastMessage("Webapps:AddApp", { id: id, app: appObject });
 
+    // At this point we don't need the manifest on the app object anymore.
+    delete appObject.updateManifest;
+    delete appObject.manifest;
+
     if (!aData.isPackage) {
       this.updateAppHandlers(null, app.manifest, app);
       if (aInstallSuccessCallback) {
         try {
           yield aInstallSuccessCallback(app, app.manifest);
         } catch (e) {
           // Ignore exceptions during the local installation of
           // an app. If it fails, the app will anyway be considered
@@ -4059,22 +4099,34 @@ this.DOMApplicationRegistry = {
     // leaking the whole page associationed with the message manager.
     aMm = Cu.getWeakReference(aMm);
 
     let response = "Webapps:Uninstall:Return:OK";
 
     try {
       aData.app = yield this._getAppWithManifest(aData.manifestURL);
 
-      let prefName = "dom.mozApps.auto_confirm_uninstall";
-      if (Services.prefs.prefHasUserValue(prefName) &&
-          Services.prefs.getBoolPref(prefName)) {
-        yield this._uninstallApp(aData.app);
+      if (this.kAndroid == aData.app.kind) {
+        debug("Uninstalling android app " + aData.app.origin);
+        let [packageName, className] =
+          AndroidUtils.getPackageAndClassFromManifestURL(aData.manifestURL);
+        Messaging.sendRequest({ type: "Apps:Uninstall",
+                                packagename: packageName,
+                                classname: className });
+        // We have to wait for Android's uninstall before sending the
+        // uninstall event, so fake an error here.
+        response = "Webapps:Uninstall:Return:KO";
       } else {
-        yield this._promptForUninstall(aData);
+        let prefName = "dom.mozApps.auto_confirm_uninstall";
+        if (Services.prefs.prefHasUserValue(prefName) &&
+            Services.prefs.getBoolPref(prefName)) {
+          yield this._uninstallApp(aData.app);
+        } else {
+          yield this._promptForUninstall(aData);
+        }
       }
     } catch (error) {
       aData.error = error;
       response = "Webapps:Uninstall:Return:KO";
     }
 
     if ((aMm = aMm.get())) {
       aMm.sendAsyncMessage(response, this.formatMessage(aData));
--- a/dom/apps/moz.build
+++ b/dom/apps/moz.build
@@ -37,16 +37,21 @@ EXTRA_JS_MODULES += [
     'MessageBroadcaster.jsm',
     'OfflineCacheInstaller.jsm',
     'PermissionsInstaller.jsm',
     'PermissionsTable.jsm',
     'StoreTrustAnchor.jsm',
     'UserCustomizations.jsm',
 ]
 
+if CONFIG['MOZ_B2GDROID']:
+    EXTRA_JS_MODULES += [
+        'AndroidUtils.jsm',
+    ]
+
 EXTRA_PP_JS_MODULES += [
     'AppsUtils.jsm',
     'ImportExport.jsm',
     'InterAppCommService.jsm',
     'OperatorApps.jsm',
     'ScriptPreloader.jsm',
     'Webapps.jsm',
 ]
--- a/mobile/android/b2gdroid/app/Makefile.in
+++ b/mobile/android/b2gdroid/app/Makefile.in
@@ -1,15 +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_MANIFEST_FILE := src/main/AndroidManifest.xml
 
 JAVAFILES := \
+  src/main/java/org/mozilla/b2gdroid/Apps.java \
   src/main/java/org/mozilla/b2gdroid/Launcher.java \
   src/main/java/org/mozilla/b2gdroid/ScreenStateObserver.java \
   $(NULL)
 
 # The GeckoView consuming APK depends on the GeckoView JAR files.  There are two
 # issues: first, the GeckoView JAR files need to be built before they are
 # consumed here.  This happens for delicate reasons.  In the (serial) libs tier,
 # base/ is traversed before b2gdroid/app.  Since base/libs builds classes.dex,
new file mode 100644
--- /dev/null
+++ b/mobile/android/b2gdroid/app/src/main/java/org/mozilla/b2gdroid/Apps.java
@@ -0,0 +1,168 @@
+/* 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.b2gdroid;
+
+import java.io.ByteArrayOutputStream;
+import java.util.Iterator;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.graphics.Bitmap;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+
+import android.net.Uri;
+import android.util.Base64;
+import android.util.Log;
+
+import org.json.JSONObject;
+import org.json.JSONArray;
+
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.util.GeckoEventListener;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+
+class Apps extends BroadcastReceiver
+           implements GeckoEventListener {
+    private static final String LOGTAG = "B2G:Apps";
+
+    private Context mContext;
+
+    Apps(Context context) {
+        mContext = context;
+        EventDispatcher.getInstance()
+                       .registerGeckoThreadListener(this,
+                                                    "Apps:GetList",
+                                                    "Apps:Launch",
+                                                    "Apps:Uninstall");
+
+        // Observe app installation and removal.
+        IntentFilter filter = new IntentFilter();
+        filter.addAction(Intent.ACTION_PACKAGE_ADDED);
+        filter.addAction(Intent.ACTION_PACKAGE_REMOVED);
+        filter.addDataScheme("package");
+        mContext.registerReceiver(this, filter);
+    }
+
+    void destroy() {
+        mContext.unregisterReceiver(this);
+        EventDispatcher.getInstance()
+                       .unregisterGeckoThreadListener(this,
+                                                      "Apps:GetList",
+                                                      "Apps:Launch",
+                                                      "Apps:Uninstall");
+    }
+
+    JSONObject activityInfoToJson(ActivityInfo info, PackageManager pm) {
+        JSONObject obj = new JSONObject();
+        try {
+            obj.put("name", info.loadLabel(pm).toString());
+            obj.put("packagename", info.packageName);
+            obj.put("classname", info.name);
+
+            final ApplicationInfo appInfo = info.applicationInfo;
+            // Pre-installed apps can't be uninstalled.
+            final boolean removable =
+                (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) == 0;
+
+            obj.put("removable", removable);
+
+            // For now, create a data: url for the icon, since we need additional
+            // android:// protocol support for icons. Once it's there we'll do
+            // something like: obj.put("icon", "android:icon/" + info.packageName);
+            Drawable d = pm.getApplicationIcon(info.packageName);
+            Bitmap bitmap = ((BitmapDrawable)d).getBitmap();
+            ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+            bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream);
+            byte[] byteArray = byteArrayOutputStream.toByteArray();
+            String encoded = Base64.encodeToString(byteArray, Base64.DEFAULT);
+            obj.put("icon", "data:image/png;base64," + encoded);
+        } catch(Exception ex) {
+            Log.wtf(LOGTAG, "Error building ActivityInfo JSON", ex);
+        }
+        return obj;
+    }
+
+    public void handleMessage(String event, JSONObject message) {
+        Log.w(LOGTAG, "Received " + event);
+
+        if ("Apps:GetList".equals(event)) {
+            JSONObject ret = new JSONObject();
+            JSONArray array = new JSONArray();
+            PackageManager pm = mContext.getPackageManager();
+            final Intent mainIntent = new Intent(Intent.ACTION_MAIN, null);
+            mainIntent.addCategory(Intent.CATEGORY_LAUNCHER);
+            final Iterator<ResolveInfo> i = pm.queryIntentActivities(mainIntent, 0).iterator();
+            try {
+                while (i.hasNext()) {
+                    ActivityInfo info = i.next().activityInfo;
+                    array.put(activityInfoToJson(info, pm));
+                }
+                ret.put("apps", array);
+            } catch(Exception ex) {
+                Log.wtf(LOGTAG, "error, making list of apps", ex);
+            }
+            EventDispatcher.sendResponse(message, ret);
+        } else if ("Apps:Launch".equals(event)) {
+            try {
+                String className = message.getString("classname");
+                String packageName = message.getString("packagename");
+                final Intent intent = new Intent(Intent.ACTION_MAIN, null);
+                intent.addCategory(Intent.CATEGORY_LAUNCHER);
+                intent.setClassName(packageName, className);
+                mContext.startActivity(intent);
+            } catch(Exception ex) {
+                Log.wtf(LOGTAG, "Error launching app", ex);
+            }
+        } else if ("Apps:Uninstall".equals(event)) {
+            try {
+                String packageName = message.getString("packagename");
+                Uri packageUri = Uri.parse("package:" + packageName);
+                final Intent intent = new Intent(Intent.ACTION_UNINSTALL_PACKAGE, packageUri);
+                mContext.startActivity(intent);
+            } catch(Exception ex) {
+                Log.wtf(LOGTAG, "Error uninstalling app", ex);
+            }
+        }
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        Log.d(LOGTAG, intent.getAction() + " " + intent.getDataString());
+
+        String packageName = intent.getDataString().substring(8);
+        String action = intent.getAction();
+        if ("android.intent.action.PACKAGE_ADDED".equals(action)) {
+            PackageManager pm = mContext.getPackageManager();
+            Intent launch = pm.getLaunchIntentForPackage(packageName);
+            if (launch == null) {
+                Log.d(LOGTAG, "No launchable intent for " + packageName);
+                return;
+            }
+            ActivityInfo info = launch.resolveActivityInfo(pm, 0);
+
+            JSONObject obj = activityInfoToJson(info, pm);
+            GeckoEvent e = GeckoEvent.createBroadcastEvent("Android:Apps:Installed", obj.toString());
+            GeckoAppShell.sendEventToGecko(e);
+        } else if ("android.intent.action.PACKAGE_REMOVED".equals(action)) {
+            JSONObject obj = new JSONObject();
+            try {
+                obj.put("packagename", packageName);
+            } catch(Exception ex) {
+                Log.wtf(LOGTAG, "Error building PACKAGE_REMOVED JSON", ex);
+            }
+            GeckoEvent e = GeckoEvent.createBroadcastEvent("Android:Apps:Uninstalled", obj.toString());
+            GeckoAppShell.sendEventToGecko(e);
+        }
+    }
+}
--- a/mobile/android/b2gdroid/app/src/main/java/org/mozilla/b2gdroid/Launcher.java
+++ b/mobile/android/b2gdroid/app/src/main/java/org/mozilla/b2gdroid/Launcher.java
@@ -1,78 +1,66 @@
 /* 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.b2gdroid;
 
-import java.io.ByteArrayOutputStream;
-import java.util.Iterator;
-
 import android.app.Activity;
 import android.app.ActivityManager;
 import android.app.KeyguardManager;
 import android.app.KeyguardManager.KeyguardLock;
 import android.content.Context;
 import android.content.Intent;
 import android.content.IntentFilter;
-import android.content.pm.ActivityInfo;
-import android.content.pm.ApplicationInfo;
-import android.content.pm.PackageManager;
-import android.content.pm.ResolveInfo;
 import android.content.SharedPreferences;
-import android.graphics.Bitmap;
-import android.graphics.drawable.BitmapDrawable;
-import android.graphics.drawable.Drawable;
-import android.net.Uri;
 import android.os.Bundle;
-import android.util.Base64;
 import android.util.Log;
 import android.view.View;
-import android.widget.ImageView;
 
 import org.json.JSONObject;
-import org.json.JSONArray;
-import org.json.JSONException;
 
 import org.mozilla.gecko.BaseGeckoInterface;
 import org.mozilla.gecko.ContactService;
 import org.mozilla.gecko.ContextGetter;
 import org.mozilla.gecko.EventDispatcher;
 import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoBatteryManager;
 import org.mozilla.gecko.GeckoEvent;
 import org.mozilla.gecko.GeckoThread;
 import org.mozilla.gecko.IntentHelper;
 import org.mozilla.gecko.util.GeckoEventListener;
 
 import org.mozilla.b2gdroid.ScreenStateObserver;
+import org.mozilla.b2gdroid.Apps;
 
 public class Launcher extends Activity
                       implements GeckoEventListener, ContextGetter {
     private static final String LOGTAG = "B2G";
 
     private ContactService      mContactService;
     private ScreenStateObserver mScreenStateObserver;
+    private Apps                mApps;
 
     /** ContextGetter */
     public Context getContext() {
         return this;
     }
 
     public SharedPreferences getSharedPreferences() {
         return null;
     }
 
     /** Initializes Gecko APIs */
     private void initGecko() {
         GeckoAppShell.setContextGetter(this);
 
         GeckoBatteryManager.getInstance().start(this);
         mContactService = new ContactService(EventDispatcher.getInstance(), this);
+        mApps = new Apps(this);
     }
 
     private void hideSplashScreen() {
         final View splash = findViewById(R.id.splashscreen);
         runOnUiThread(new Runnable() {
             @Override public void run() {
                 splash.setVisibility(View.GONE);
             }
@@ -118,16 +106,17 @@ public class Launcher extends Activity
         IntentHelper.destroy();
         mScreenStateObserver.destroy(this);
         mScreenStateObserver = null;
 
         EventDispatcher.getInstance().unregisterGeckoThreadListener(this,
             "Launcher:Ready");
 
         mContactService.destroy();
+        mApps.destroy();
     }
 
     @Override
     protected void onNewIntent(Intent intent) {
         final String action = intent.getAction();
         Log.w(LOGTAG, "onNewIntent " + action);
         if (Intent.ACTION_VIEW.equals(action)) {
             Log.w(LOGTAG, "Asking gecko to view " + intent.getDataString());