bug 934756 - implement install/launch/uninstall flows for webapps on Android using synthetic APKs; r=wesj, fabrice
authorMyk Melez <myk@mozilla.org>
Thu, 09 Jan 2014 10:18:55 -0800
changeset 178734 4c31c82d98fc28fa680a1b92299561c81d26c757
parent 178733 0b0a43ce2da87da32e9fd03bf880003e558d6547
child 178735 04b340ad31916b6930804b525768d7f7fec7706e
push id3343
push userffxbld
push dateMon, 17 Mar 2014 21:55:32 +0000
treeherdermozilla-beta@2f7d3415f79f [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerswesj, fabrice
bugs934756
milestone29.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 934756 - implement install/launch/uninstall flows for webapps on Android using synthetic APKs; r=wesj, fabrice
CLOBBER
configure.in
dom/apps/src/AppsUtils.jsm
dom/apps/src/Webapps.jsm
mobile/android/app/mobile.js
mobile/android/base/AndroidManifest.xml.in
mobile/android/base/AppConstants.java.in
mobile/android/base/GeckoApp.java
mobile/android/base/GeckoAppShell.java
mobile/android/base/GeckoProfile.java
mobile/android/base/Makefile.in
mobile/android/base/WebApp.java.in
mobile/android/base/WebAppAllocator.java
mobile/android/base/WebAppManifestFragment.xml.frag
mobile/android/base/WebAppManifestFragment.xml.frag.in
mobile/android/base/gfx/BitmapUtils.java
mobile/android/base/moz.build
mobile/android/base/webapp/ApkResources.java
mobile/android/base/webapp/InstallHelper.java
mobile/android/base/webapp/InstallListener.java
mobile/android/base/webapp/UninstallListener.java
mobile/android/base/webapp/WebAppAllocator.java
mobile/android/base/webapp/WebAppDispatcher.java
mobile/android/base/webapp/WebAppImpl.java
mobile/android/chrome/content/browser.js
mobile/android/modules/WebappManager.jsm
mobile/android/modules/WebappManagerWorker.js
mobile/android/modules/moz.build
--- a/CLOBBER
+++ b/CLOBBER
@@ -17,9 +17,9 @@
 #
 # Modifying this file will now automatically clobber the buildbot machines \o/
 #
 
 # Are you updating CLOBBER because you think it's needed for your WebIDL
 # changes to stick? As of bug 928195, this shouldn't be necessary! Please
 # don't change CLOBBER for WebIDL changes any more.
 
-Bug 956240 requires clobber due to bug 956723
+Bug 934756 causes the Fennec build to fail unless we clobber.
--- a/configure.in
+++ b/configure.in
@@ -4046,16 +4046,17 @@ if test -n "$MOZ_RTSP"; then
   NECKO_PROTOCOLS_DEFAULT="$NECKO_PROTOCOLS_DEFAULT rtsp"
 fi
 USE_ARM_KUSER=
 BUILD_CTYPES=1
 MOZ_USE_NATIVE_POPUP_WINDOWS=
 MOZ_ANDROID_HISTORY=
 MOZ_WEBSMS_BACKEND=
 MOZ_ANDROID_BEAM=
+MOZ_ANDROID_SYNTHAPKS=
 ACCESSIBILITY=1
 MOZ_TIME_MANAGER=
 MOZ_PAY=
 MOZ_AUDIO_CHANNEL_MANAGER=
 NSS_NO_LIBPKIX=
 MOZ_CONTENT_SANDBOX=
 MOZ_CONTENT_SANDBOX_REPORTER=
 
@@ -5007,16 +5008,28 @@ fi
 dnl ========================================================
 dnl = Enable NFC permission on Android
 dnl ========================================================
 if test -n "$MOZ_ANDROID_BEAM"; then
     AC_DEFINE(MOZ_ANDROID_BEAM)
 fi
 
 dnl ========================================================
+dnl = Synthesized Webapp APKs on Android
+dnl ========================================================
+MOZ_ARG_ENABLE_BOOL(android-synthapks,
+[  --enable-android-synthapks       Enable synthesized APKs],
+    MOZ_ANDROID_SYNTHAPKS=1,
+    MOZ_ANDROID_SYNTHAPKS=)
+
+if test -n "$MOZ_ANDROID_SYNTHAPKS"; then
+    AC_DEFINE(MOZ_ANDROID_SYNTHAPKS)
+fi
+
+dnl ========================================================
 dnl = JS Debugger XPCOM component (js/jsd)
 dnl ========================================================
 MOZ_ARG_DISABLE_BOOL(jsd,
 [  --disable-jsd           Disable JavaScript debug library],
     MOZ_JSDEBUGGER=,
     MOZ_JSDEBUGGER=1)
 
 
@@ -8323,16 +8336,17 @@ AC_SUBST(MOZ_DIRECTX_SDK_CPU_SUFFIX)
 AC_SUBST(MOZ_D3DX9_VERSION)
 AC_SUBST(MOZ_D3DCOMPILER_CAB)
 AC_SUBST(MOZ_D3DCOMPILER_DLL)
 AC_SUBST(MOZ_METRO)
 
 AC_SUBST(MOZ_ANDROID_HISTORY)
 AC_SUBST(MOZ_WEBSMS_BACKEND)
 AC_SUBST(MOZ_ANDROID_BEAM)
+AC_SUBST(MOZ_ANDROID_SYNTHAPKS)
 AC_SUBST(MOZ_DISABLE_GECKOVIEW)
 AC_SUBST(ENABLE_STRIP)
 AC_SUBST(PKG_SKIP_STRIP)
 AC_SUBST(STRIP_FLAGS)
 AC_SUBST(USE_ELF_HACK)
 AC_SUBST(INCREMENTAL_LINKER)
 AC_SUBST(MOZ_COMPONENTS_VERSION_SCRIPT_LDFLAGS)
 AC_SUBST(MOZ_COMPONENT_NSPR_LIBS)
--- a/dom/apps/src/AppsUtils.jsm
+++ b/dom/apps/src/AppsUtils.jsm
@@ -65,16 +65,19 @@ mozIApplication.prototype = {
 this.AppsUtils = {
   // Clones a app, without the manifest.
   cloneAppObject: function cloneAppObject(aApp) {
     return {
       name: aApp.name,
       csp: aApp.csp,
       installOrigin: aApp.installOrigin,
       origin: aApp.origin,
+#ifdef MOZ_ANDROID_SYNTHAPKS
+      packageName: aApp.packageName,
+#endif
       receipts: aApp.receipts ? JSON.parse(JSON.stringify(aApp.receipts)) : null,
       installTime: aApp.installTime,
       manifestURL: aApp.manifestURL,
       appStatus: aApp.appStatus,
       removable: aApp.removable,
       id: aApp.id,
       localId: aApp.localId,
       basePath: aApp.basePath,
--- a/dom/apps/src/Webapps.jsm
+++ b/dom/apps/src/Webapps.jsm
@@ -1114,19 +1114,24 @@ this.DOMApplicationRegistry = {
       }
     }
 
     let msg = aMessage.data || {};
     let mm = aMessage.target;
     msg.mm = mm;
 
     switch (aMessage.name) {
-      case "Webapps:Install":
+      case "Webapps:Install": {
+#ifdef MOZ_ANDROID_SYNTHAPKS
+        Services.obs.notifyObservers(null, "webapps-download-apk", JSON.stringify(msg));
+#else
         this.doInstall(msg, mm);
+#endif
         break;
+      }
       case "Webapps:GetSelf":
         this.getSelf(msg, mm);
         break;
       case "Webapps:Uninstall":
         this.doUninstall(msg, mm);
         break;
       case "Webapps:Launch":
         this.doLaunch(msg, mm);
@@ -1138,19 +1143,24 @@ this.DOMApplicationRegistry = {
         this.getInstalled(msg, mm);
         break;
       case "Webapps:GetNotInstalled":
         this.getNotInstalled(msg, mm);
         break;
       case "Webapps:GetAll":
         this.doGetAll(msg, mm);
         break;
-      case "Webapps:InstallPackage":
+      case "Webapps:InstallPackage": {
+#ifdef MOZ_ANDROID_SYNTHAPKS
+        Services.obs.notifyObservers(null, "webapps-download-apk", JSON.stringify(msg));
+#else
         this.doInstallPackage(msg, mm);
+#endif
         break;
+      }
       case "Webapps:RegisterForMessages":
         this.addMessageListener(msg.messages, msg.app, mm);
         break;
       case "Webapps:UnregisterForMessages":
         this.removeMessageListener(msg, mm);
         break;
       case "child-process-shutdown":
         this.removeMessageListener(["Webapps:Internal:AllMessages"], mm);
@@ -2007,16 +2017,75 @@ this.DOMApplicationRegistry = {
 
     // Hosted apps can't be trusted or certified, so just check that the
     // manifest doesn't ask for those.
     function checkAppStatus(aManifest) {
       let manifestStatus = aManifest.type || "web";
       return manifestStatus === "web";
     }
 
+    let checkManifest = (function() {
+      if (!app.manifest) {
+        sendError("MANIFEST_PARSE_ERROR");
+        return false;
+      }
+
+      // Disallow multiple hosted apps installations from the same origin for now.
+      // We will remove this code after multiple apps per origin are supported (bug 778277).
+      // This will also disallow reinstalls from the same origin for now.
+      for (let id in this.webapps) {
+        if (this.webapps[id].origin == app.origin &&
+            !this.webapps[id].packageHash &&
+            this._isLaunchable(this.webapps[id])) {
+          sendError("MULTIPLE_APPS_PER_ORIGIN_FORBIDDEN");
+          return false;
+        }
+      }
+
+      if (!AppsUtils.checkManifest(app.manifest, app)) {
+        sendError("INVALID_MANIFEST");
+        return false;
+      }
+
+      if (!AppsUtils.checkInstallAllowed(app.manifest, app.installOrigin)) {
+        sendError("INSTALL_FROM_DENIED");
+        return false;
+      }
+
+      if (!checkAppStatus(app.manifest)) {
+        sendError("INVALID_SECURITY_LEVEL");
+        return false;
+      }
+
+      return true;
+    }).bind(this);
+
+    let installApp = (function() {
+      app.manifestHash = this.computeManifestHash(app.manifest);
+      // We allow bypassing the install confirmation process to facilitate
+      // automation.
+      let prefName = "dom.mozApps.auto_confirm_install";
+      if (Services.prefs.prefHasUserValue(prefName) &&
+          Services.prefs.getBoolPref(prefName)) {
+        this.confirmInstall(aData);
+      } else {
+        Services.obs.notifyObservers(aMm, "webapps-ask-install",
+                                     JSON.stringify(aData));
+      }
+    }).bind(this);
+
+    // We may already have the manifest (e.g. AutoInstall),
+    // in which case we don't need to load it.
+    if (app.manifest) {
+      if (checkManifest()) {
+        installApp();
+      }
+      return;
+    }
+
     let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
                 .createInstance(Ci.nsIXMLHttpRequest);
     xhr.open("GET", app.manifestURL, true);
     xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
     xhr.channel.notificationCallbacks = this.createLoadContext(aData.appId,
                                                                aData.isBrowser);
     xhr.responseType = "json";
 
@@ -2024,52 +2093,19 @@ this.DOMApplicationRegistry = {
       if (xhr.status == 200) {
         if (!AppsUtils.checkManifestContentType(app.installOrigin, app.origin,
                                                 xhr.getResponseHeader("content-type"))) {
           sendError("INVALID_MANIFEST");
           return;
         }
 
         app.manifest = xhr.response;
-        if (!app.manifest) {
-          sendError("MANIFEST_PARSE_ERROR");
-          return;
-        }
-
-        // Disallow multiple hosted apps installations from the same origin for now.
-        // We will remove this code after multiple apps per origin are supported (bug 778277).
-        // This will also disallow reinstalls from the same origin for now.
-        for (let id in this.webapps) {
-          if (this.webapps[id].origin == app.origin &&
-              !this.webapps[id].packageHash &&
-              this._isLaunchable(this.webapps[id])) {
-            sendError("MULTIPLE_APPS_PER_ORIGIN_FORBIDDEN");
-            return;
-          }
-        }
-
-        if (!AppsUtils.checkManifest(app.manifest, app)) {
-          sendError("INVALID_MANIFEST");
-        } else if (!AppsUtils.checkInstallAllowed(app.manifest, app.installOrigin)) {
-          sendError("INSTALL_FROM_DENIED");
-        } else if (!checkAppStatus(app.manifest)) {
-          sendError("INVALID_SECURITY_LEVEL");
-        } else {
+        if (checkManifest()) {
           app.etag = xhr.getResponseHeader("Etag");
-          app.manifestHash = this.computeManifestHash(app.manifest);
-          // We allow bypassing the install confirmation process to facilitate
-          // automation.
-          let prefName = "dom.mozApps.auto_confirm_install";
-          if (Services.prefs.prefHasUserValue(prefName) &&
-              Services.prefs.getBoolPref(prefName)) {
-            this.confirmInstall(aData);
-          } else {
-            Services.obs.notifyObservers(aMm, "webapps-ask-install",
-                                         JSON.stringify(aData));
-          }
+          installApp();
         }
       } else {
         sendError("MANIFEST_URL_ERROR");
       }
     }).bind(this), false);
 
     xhr.addEventListener("error", (function() {
       sendError("NETWORK_ERROR");
@@ -2083,64 +2119,88 @@ this.DOMApplicationRegistry = {
 
     let sendError = function sendError(aError) {
       aData.error = aError;
       aMm.sendAsyncMessage("Webapps:Install:Return:KO", aData);
       Cu.reportError("Error installing packaged app from: " +
                      app.installOrigin + ": " + aError);
     }.bind(this);
 
+    let checkUpdateManifest = (function() {
+      let manifest = app.updateManifest;
+
+      // Disallow reinstalls from the same manifest URL for now.
+      let id = this._appIdForManifestURL(app.manifestURL);
+      if (id !== null && this._isLaunchable(this.webapps[id])) {
+        sendError("REINSTALL_FORBIDDEN");
+        return false;
+      }
+
+      if (!(AppsUtils.checkManifest(manifest, app) && manifest.package_path)) {
+        sendError("INVALID_MANIFEST");
+        return false;
+      }
+
+      if (!AppsUtils.checkInstallAllowed(manifest, app.installOrigin)) {
+        sendError("INSTALL_FROM_DENIED");
+        return false;
+      }
+
+      return true;
+    }).bind(this);
+
+    let installApp = (function() {
+      app.manifestHash = this.computeManifestHash(app.updateManifest);
+
+      // We allow bypassing the install confirmation process to facilitate
+      // automation.
+      let prefName = "dom.mozApps.auto_confirm_install";
+      if (Services.prefs.prefHasUserValue(prefName) &&
+          Services.prefs.getBoolPref(prefName)) {
+        this.confirmInstall(aData);
+      } else {
+        Services.obs.notifyObservers(aMm, "webapps-ask-install",
+                                     JSON.stringify(aData));
+      }
+    }).bind(this);
+
+    // We may already have the manifest (e.g. AutoInstall),
+    // in which case we don't need to load it.
+    if (app.updateManifest) {
+      if (checkUpdateManifest()) {
+        installApp();
+      }
+      return;
+    }
+
     let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
                 .createInstance(Ci.nsIXMLHttpRequest);
     xhr.open("GET", app.manifestURL, true);
     xhr.channel.loadFlags |= Ci.nsIRequest.INHIBIT_CACHING;
     xhr.channel.notificationCallbacks = this.createLoadContext(aData.appId,
                                                                aData.isBrowser);
     xhr.responseType = "json";
 
     xhr.addEventListener("load", (function() {
       if (xhr.status == 200) {
         if (!AppsUtils.checkManifestContentType(app.installOrigin, app.origin,
                                                 xhr.getResponseHeader("content-type"))) {
           sendError("INVALID_MANIFEST");
           return;
         }
 
-        let manifest = app.updateManifest = xhr.response;
-        if (!manifest) {
+        app.updateManifest = xhr.response;
+        if (!app.updateManifest) {
           sendError("MANIFEST_PARSE_ERROR");
           return;
         }
-
-        // Disallow reinstalls from the same manifest URL for now.
-        let id = this._appIdForManifestURL(app.manifestURL);
-        if (id !== null && this._isLaunchable(this.webapps[id])) {
-          sendError("REINSTALL_FORBIDDEN");
-          return;
-        }
-
-        if (!(AppsUtils.checkManifest(manifest, app) &&
-              manifest.package_path)) {
-          sendError("INVALID_MANIFEST");
-        } else if (!AppsUtils.checkInstallAllowed(manifest, app.installOrigin)) {
-          sendError("INSTALL_FROM_DENIED");
-        } else {
+        if (checkUpdateManifest()) {
           app.etag = xhr.getResponseHeader("Etag");
-          app.manifestHash = this.computeManifestHash(manifest);
           debug("at install package got app etag=" + app.etag);
-          // We allow bypassing the install confirmation process to facilitate
-          // automation.
-          let prefName = "dom.mozApps.auto_confirm_install";
-          if (Services.prefs.prefHasUserValue(prefName) &&
-              Services.prefs.getBoolPref(prefName)) {
-            this.confirmInstall(aData);
-          } else {
-            Services.obs.notifyObservers(aMm, "webapps-ask-install",
-                                         JSON.stringify(aData));
-          }
+          installApp();
         }
       }
       else {
         sendError("MANIFEST_URL_ERROR");
       }
     }).bind(this), false);
 
     xhr.addEventListener("error", (function() {
@@ -2341,36 +2401,52 @@ onInstallSuccessAck: function onInstallS
       }
     }
 
     // We notify about the successful installation via mgmt.oninstall and the
     // corresponging DOMRequest.onsuccess event as soon as the app is properly
     // saved in the registry.
     this._saveApps((function() {
       this.broadcastMessage("Webapps:AddApp", { id: id, app: appObject });
-      this.broadcastMessage("Webapps:Install:Return:OK", aData);
+      if (aData.isPackage && aData.autoInstall) {
+        // Skip directly to onInstallSuccessAck, since there isn't
+        // a WebappsRegistry to receive Webapps:Install:Return:OK and respond
+        // Webapps:Install:Return:Ack when an app is being auto-installed.
+        this.onInstallSuccessAck(app.manifestURL);
+      } else {
+        // Broadcast Webapps:Install:Return:OK so the WebappsRegistry can notify
+        // the installing page about the successful install, after which it'll
+        // respond Webapps:Install:Return:Ack, which calls onInstallSuccessAck.
+        this.broadcastMessage("Webapps:Install:Return:OK", aData);
+      }
       Services.obs.notifyObservers(null, "webapps-installed",
         JSON.stringify({ manifestURL: app.manifestURL }));
     }).bind(this));
 
     if (!aData.isPackage) {
       this.updateAppHandlers(null, app.manifest, app);
       if (aInstallSuccessCallback) {
         aInstallSuccessCallback(app.manifest);
       }
     }
     let dontNeedNetwork = false;
     if (manifest.package_path) {
       // If it is a local app then it must been installed from a local file
       // instead of web.
-      let origPath = jsonManifest.package_path;
+#ifdef MOZ_ANDROID_SYNTHAPKS
+      // In that case, we would already have the manifest, not just the update
+      // manifest.
+      dontNeedNetwork = !!aData.app.manifest;
+#else
       if (aData.app.localInstallPath) {
         dontNeedNetwork = true;
         jsonManifest.package_path = "file://" + aData.app.localInstallPath;
       }
+#endif
+
       // origin for install apps is meaningless here, since it's app:// and this
       // can't be used to resolve package paths.
       manifest = new ManifestHelper(jsonManifest, app.manifestURL);
 
       this.queuedPackageDownload[app.manifestURL] = {
         manifest: manifest,
         app: appObject,
         callback: aInstallSuccessCallback
@@ -2543,17 +2619,17 @@ onInstallSuccessAck: function onInstallS
     let oldApp = this.webapps[id];
 
     return Task.spawn((function*() {
       yield this._ensureSufficientStorage(aNewApp);
 
       let fullPackagePath = aManifest.fullPackagePath();
 
       // Check if it's a local file install (we've downloaded/sideloaded the
-      // package already or it did exist on the build).
+      // package already, it existed on the build, or it came with an APK).
       // Note that this variable also controls whether files signed with expired
       // certificates are accepted or not. If isLocalFileInstall is true and the
       // device date is earlier than the build generation date, then the signature
       // will be accepted even if the certificate is expired.
       let isLocalFileInstall =
         Services.io.extractScheme(fullPackagePath) === 'file';
 
       debug("About to download " + fullPackagePath);
--- a/mobile/android/app/mobile.js
+++ b/mobile/android/app/mobile.js
@@ -808,8 +808,14 @@ pref("browser.snippets.updateInterval", 
 // URL used to check for user's country code
 pref("browser.snippets.geoUrl", "https://geo.mozilla.org/country.json");
 
 // URL used to ping metrics with stats about which snippets have been shown
 pref("browser.snippets.statsUrl", "https://snippets-stats.mozilla.org/mobile");
 
 // This pref requires a restart to take effect.
 pref("browser.snippets.enabled", false);
+
+#ifdef MOZ_ANDROID_SYNTHAPKS
+// The URL of the APK factory from which we obtain APKs for webapps.
+// XXX This currently points to jhugman's prototype server.
+pref("browser.webapps.apkFactoryUrl", "http://107.22.148.17:8080/application.apk");
+#endif
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -151,16 +151,35 @@
 
             <!-- For debugging -->
             <intent-filter>
                 <action android:name="org.mozilla.gecko.DEBUG" />
                 <category android:name="android.intent.category.DEFAULT" />
             </intent-filter>
         </activity>
 
+#ifdef MOZ_ANDROID_SYNTHAPKS
+        <activity android:name="org.mozilla.gecko.webapp.WebAppDispatcher"
+            android:noHistory="true" >
+            <intent-filter>
+                <!-- catch links from synthetic apks -->
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <data android:mimeType="application/webapp" />
+            </intent-filter>
+        </activity>
+
+        <receiver android:name="org.mozilla.gecko.webapp.UninstallListener" >
+          <intent-filter>
+             <action android:name="android.intent.action.PACKAGE_REMOVED" />
+             <data android:scheme="package" />
+          </intent-filter>
+        </receiver>
+#endif
+
         <activity android:name=".WebApp"
                   android:label="@string/webapp_generic_name"
                   android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize"
                   android:windowSoftInputMode="stateUnspecified|adjustResize"
                   android:launchMode="singleTask"
                   android:taskAffinity="org.mozilla.gecko.WEBAPP"
                   android:process=":@ANDROID_PACKAGE_NAME@.WebApp"
                   android:excludeFromRecents="true"
@@ -173,17 +192,17 @@
             </intent-filter>
         </activity>
 
         <!-- Declare a predefined number of WebApp<num> activities. These are
              used so that each web app can launch in its own process. Keep
              this number in sync with the total number of web apps handled in
              WebAppAllocator. -->
 
-#define FRAGMENT WebAppManifestFragment.xml.frag
+#define FRAGMENT WebAppManifestFragment.xml.frag.in
 #include WebAppFragmentRepeater.inc
 
         <!-- Masquerade as the Resolver so that we can be opened from the Marketplace. -->
         <activity-alias
             android:name="com.android.internal.app.ResolverActivity"
             android:targetActivity=".App"
             android:exported="true" />
 
--- a/mobile/android/base/AppConstants.java.in
+++ b/mobile/android/base/AppConstants.java.in
@@ -134,16 +134,23 @@ public class AppConstants {
 
     public static final boolean MOZ_ANDROID_BEAM =
 #ifdef MOZ_ANDROID_BEAM
     true;
 #else
     false;
 #endif
 
+    public static final boolean MOZ_ANDROID_SYNTHAPKS =
+#ifdef MOZ_ANDROID_SYNTHAPKS
+    true;
+#else
+    false;
+#endif
+
     // See this wiki page for more details about channel specific build defines:
     // https://wiki.mozilla.org/Platform/Channel-specific_build_defines
     public static final boolean RELEASE_BUILD =
 #ifdef RELEASE_BUILD
     true;
 #else
     false;
 #endif
--- a/mobile/android/base/GeckoApp.java
+++ b/mobile/android/base/GeckoApp.java
@@ -23,16 +23,17 @@ import org.mozilla.gecko.updater.UpdateS
 import org.mozilla.gecko.updater.UpdateServiceHelper;
 import org.mozilla.gecko.util.ActivityResultHandler;
 import org.mozilla.gecko.util.EventDispatcher;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.GeckoEventResponder;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.util.UiAsyncTask;
+import org.mozilla.gecko.webapp.UninstallListener;
 import org.mozilla.gecko.widget.ButtonToast;
 
 import org.json.JSONArray;
 import org.json.JSONException;
 import org.json.JSONObject;
 
 import android.app.Activity;
 import android.app.AlertDialog;
@@ -50,16 +51,17 @@ import android.graphics.RectF;
 import android.hardware.Sensor;
 import android.hardware.SensorEvent;
 import android.hardware.SensorEventListener;
 import android.location.Location;
 import android.location.LocationListener;
 import android.net.wifi.ScanResult;
 import android.net.Uri;
 import android.net.wifi.WifiManager;
+import android.os.Environment;
 import android.os.Build;
 import android.os.Bundle;
 import android.os.Handler;
 import android.os.PowerManager;
 import android.os.StrictMode;
 import android.preference.PreferenceManager;
 import android.provider.ContactsContract;
 import android.provider.MediaStore.Images.Media;
@@ -626,47 +628,44 @@ public abstract class GeckoApp
             } else if (event.equals("Accessibility:Ready")) {
                 GeckoAccessibility.updateAccessibilitySettings(this);
             } else if (event.equals("Shortcut:Remove")) {
                 final String url = message.getString("url");
                 final String origin = message.getString("origin");
                 final String title = message.getString("title");
                 final String type = message.getString("shortcutType");
                 GeckoAppShell.removeShortcut(title, url, origin, type);
+            } else if (AppConstants.MOZ_ANDROID_SYNTHAPKS && event.equals("WebApps:InstallApk")) {
+                GeckoAppShell.installApk(this, message.getString("filePath"), message.getString("data"));
+            } else if (!AppConstants.MOZ_ANDROID_SYNTHAPKS && event.equals("WebApps:PreInstall")) {
+                String name = message.getString("name");
+                String manifestURL = message.getString("manifestURL");
+                String origin = message.getString("origin");
+                // preInstallWebapp will return a File object pointing to the profile directory of the webapp
+                mCurrentResponse = GeckoAppShell.preInstallWebApp(name, manifestURL, origin).toString();
+            } else if (event.equals("WebApps:PostInstall")) {
+                if (AppConstants.MOZ_ANDROID_SYNTHAPKS) {
+                    GeckoAppShell.postInstallWebApp(message.getString("packageName"), message.getString("origin"));
+                } else {
+                    String name = message.getString("name");
+                    String manifestURL = message.getString("manifestURL");
+                    String iconURL = message.getString("iconURL");
+                    String originalOrigin = message.getString("originalOrigin");
+                    String origin = message.getString("origin");
+                    GeckoAppShell.postInstallWebApp(name, manifestURL, origin, iconURL, originalOrigin);
+                }
             } else if (event.equals("WebApps:Open")) {
                 String manifestURL = message.getString("manifestURL");
                 String origin = message.getString("origin");
                 Intent intent = GeckoAppShell.getWebAppIntent(manifestURL, origin, "", null);
                 if (intent == null)
                     return;
                 startActivity(intent);
-            } else if (event.equals("WebApps:Install")) {
-                String name = message.getString("name");
-                String manifestURL = message.getString("manifestURL");
-                String iconURL = message.getString("iconURL");
-                String origin = message.getString("origin");
-                // preInstallWebapp will return a File object pointing to the profile directory of the webapp
-                mCurrentResponse = GeckoAppShell.preInstallWebApp(name, manifestURL, origin).toString();
-                GeckoAppShell.postInstallWebApp(name, manifestURL, origin, iconURL, origin);
-            } else if (event.equals("WebApps:PreInstall")) {
-                String name = message.getString("name");
-                String manifestURL = message.getString("manifestURL");
-                String origin = message.getString("origin");
-                // preInstallWebapp will return a File object pointing to the profile directory of the webapp
-                mCurrentResponse = GeckoAppShell.preInstallWebApp(name, manifestURL, origin).toString();
-            } else if (event.equals("WebApps:PostInstall")) {
-                String name = message.getString("name");
-                String manifestURL = message.getString("manifestURL");
-                String iconURL = message.getString("iconURL");
-                String originalOrigin = message.getString("originalOrigin");
-                String origin = message.getString("origin");
-                GeckoAppShell.postInstallWebApp(name, manifestURL, origin, iconURL, originalOrigin);
-            } else if (event.equals("WebApps:Uninstall")) {
-                String origin = message.getString("origin");
-                GeckoAppShell.uninstallWebApp(origin);
+            } else if (!AppConstants.MOZ_ANDROID_SYNTHAPKS && event.equals("WebApps:Uninstall")) {
+                GeckoAppShell.uninstallWebApp(message.getString("origin"));
             } else if (event.equals("Share:Text")) {
                 String text = message.getString("text");
                 GeckoAppShell.openUriExternal(text, "text/plain", "", "", Intent.ACTION_SEND, "");
             } else if (event.equals("Share:Image")) {
                 String src = message.getString("url");
                 String type = message.getString("mime");
                 GeckoAppShell.shareImage(src, type);
             } else if (event.equals("Image:SetAs")) {
@@ -1337,16 +1336,21 @@ public abstract class GeckoApp
 
                 final String uiLocale = appLocale;
                 ThreadUtils.postToUiThread(new Runnable() {
                     @Override
                     public void run() {
                         GeckoApp.this.onLocaleReady(uiLocale);
                     }
                 });
+
+                // Perform webapp uninstalls as appropiate.
+                if (AppConstants.MOZ_ANDROID_SYNTHAPKS) {
+                    UninstallListener.initUninstallPackageScan(getApplicationContext());
+                }
             }
         });
 
         GeckoAppShell.setNotificationClient(makeNotificationClient());
         NotificationHelper.init(getApplicationContext());
     }
 
     /**
@@ -1553,20 +1557,21 @@ public abstract class GeckoApp
         registerEventListener("ToggleChrome:Show");
         registerEventListener("ToggleChrome:Focus");
         registerEventListener("Permissions:Data");
         registerEventListener("Session:StatePurged");
         registerEventListener("Bookmark:Insert");
         registerEventListener("Accessibility:Event");
         registerEventListener("Accessibility:Ready");
         registerEventListener("Shortcut:Remove");
-        registerEventListener("WebApps:Open");
+        // TODO Consider moving webapp install-related things into InstallHelper.
+        registerEventListener("WebApps:InstallApk");
         registerEventListener("WebApps:PreInstall");
         registerEventListener("WebApps:PostInstall");
-        registerEventListener("WebApps:Install");
+        registerEventListener("WebApps:Open");
         registerEventListener("WebApps:Uninstall");
         registerEventListener("Share:Text");
         registerEventListener("Share:Image");
         registerEventListener("Image:SetAs");
         registerEventListener("Sanitize:ClearHistory");
         registerEventListener("Update:Check");
         registerEventListener("Update:Download");
         registerEventListener("Update:Install");
@@ -1893,16 +1898,19 @@ public abstract class GeckoApp
             String uri = intent.getDataString();
             Tabs.getInstance().loadUrl(uri);
         } else if (Intent.ACTION_VIEW.equals(action)) {
             String uri = intent.getDataString();
             Tabs.getInstance().loadUrl(uri, Tabs.LOADURL_NEW_TAB |
                                             Tabs.LOADURL_USER_ENTERED |
                                             Tabs.LOADURL_EXTERNAL);
         } else if (action != null && action.startsWith(ACTION_WEBAPP_PREFIX)) {
+            // A lightweight mechanism for loading a web page as a webapp
+            // without installing the app natively nor registering it in the DOM
+            // application registry.
             String uri = getURIFromIntent(intent);
             GeckoAppShell.sendEventToGecko(GeckoEvent.createWebappLoadEvent(uri));
         } else if (ACTION_BOOKMARK.equals(action)) {
             String uri = getURIFromIntent(intent);
             GeckoAppShell.sendEventToGecko(GeckoEvent.createBookmarkLoadEvent(uri));
         } else if (Intent.ACTION_SEARCH.equals(action)) {
             String uri = getURIFromIntent(intent);
             GeckoAppShell.sendEventToGecko(GeckoEvent.createURILoadEvent(uri));
@@ -2080,20 +2088,20 @@ public abstract class GeckoApp
         unregisterEventListener("ToggleChrome:Focus");
         unregisterEventListener("Permissions:Data");
         unregisterEventListener("Tab:ViewportMetadata");
         unregisterEventListener("Session:StatePurged");
         unregisterEventListener("Bookmark:Insert");
         unregisterEventListener("Accessibility:Event");
         unregisterEventListener("Accessibility:Ready");
         unregisterEventListener("Shortcut:Remove");
-        unregisterEventListener("WebApps:Open");
+        unregisterEventListener("WebApps:InstallApk");
         unregisterEventListener("WebApps:PreInstall");
         unregisterEventListener("WebApps:PostInstall");
-        unregisterEventListener("WebApps:Install");
+        unregisterEventListener("WebApps:Open");
         unregisterEventListener("WebApps:Uninstall");
         unregisterEventListener("Share:Text");
         unregisterEventListener("Share:Image");
         unregisterEventListener("Image:SetAs");
         unregisterEventListener("Sanitize:ClearHistory");
         unregisterEventListener("Update:Check");
         unregisterEventListener("Update:Download");
         unregisterEventListener("Update:Install");
--- a/mobile/android/base/GeckoAppShell.java
+++ b/mobile/android/base/GeckoAppShell.java
@@ -11,28 +11,34 @@ import org.mozilla.gecko.gfx.GeckoLayerC
 import org.mozilla.gecko.gfx.LayerView;
 import org.mozilla.gecko.gfx.PanZoomController;
 import org.mozilla.gecko.mozglue.JNITarget;
 import org.mozilla.gecko.mozglue.generatorannotations.OptionalGeneratedParameter;
 import org.mozilla.gecko.mozglue.generatorannotations.WrapElementForJNI;
 import org.mozilla.gecko.prompts.PromptService;
 import org.mozilla.gecko.mozglue.GeckoLoader;
 import org.mozilla.gecko.mozglue.RobocopTarget;
+import org.mozilla.gecko.util.ActivityResultHandler;
 import org.mozilla.gecko.util.EventDispatcher;
 import org.mozilla.gecko.util.GeckoEventListener;
 import org.mozilla.gecko.util.HardwareUtils;
 import org.mozilla.gecko.util.ProxySelector;
 import org.mozilla.gecko.util.ThreadUtils;
+import org.mozilla.gecko.webapp.InstallListener;
+
+import org.json.JSONException;
+import org.json.JSONObject;
 
 import android.app.Activity;
 import android.app.ActivityManager;
 import android.app.PendingIntent;
 import android.content.ActivityNotFoundException;
 import android.content.Context;
 import android.content.Intent;
+import android.content.IntentFilter;
 import android.content.SharedPreferences;
 import android.content.pm.ActivityInfo;
 import android.content.pm.ApplicationInfo;
 import android.content.pm.PackageInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.PackageManager.NameNotFoundException;
 import android.content.pm.ResolveInfo;
 import android.content.pm.ServiceInfo;
@@ -57,16 +63,17 @@ import android.location.Location;
 import android.location.LocationListener;
 import android.location.LocationManager;
 import android.media.MediaScannerConnection;
 import android.media.MediaScannerConnection.MediaScannerConnectionClient;
 import android.net.ConnectivityManager;
 import android.net.NetworkInfo;
 import android.net.Uri;
 import android.os.Build;
+import android.os.Environment;
 import android.os.Handler;
 import android.os.Looper;
 import android.os.Message;
 import android.os.MessageQueue;
 import android.os.SystemClock;
 import android.os.Vibrator;
 import android.provider.Settings;
 import android.support.v4.util.LruCache;
@@ -92,16 +99,17 @@ import java.io.File;
 import java.io.FileOutputStream;
 import java.io.FileReader;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
 import java.io.OutputStream;
 import java.io.PrintWriter;
 import java.io.StringWriter;
+import java.net.MalformedURLException;
 import java.net.Proxy;
 import java.net.URI;
 import java.net.URL;
 import java.nio.ByteBuffer;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.LinkedList;
@@ -367,16 +375,17 @@ public class GeckoAppShell
         ThreadUtils.postToUiThread(new Runnable() {
                 @Override
                 public void run() {
                     geckoLoaded();
                 }
             });
 
         // and go
+        Log.d(LOGTAG, "GeckoLoader.nativeRun " + combinedArgs);
         GeckoLoader.nativeRun(combinedArgs);
 
         // Remove pumpMessageLoop() idle handler
         Looper.myQueue().removeIdleHandler(idleHandler);
     }
 
     // Called on the UI thread after Gecko loads.
     private static void geckoLoaded() {
@@ -695,43 +704,71 @@ public class GeckoAppShell
         System.exit(0);
     }
 
     @WrapElementForJNI
     static void scheduleRestart() {
         gRestartScheduled = true;
     }
 
+    // The old implementation of preInstallWebApp.  Not used by MOZ_ANDROID_SYNTHAPKS.
     public static File preInstallWebApp(String aTitle, String aURI, String aOrigin) {
         int index = WebAppAllocator.getInstance(getContext()).findAndAllocateIndex(aOrigin, aTitle, (String) null);
         GeckoProfile profile = GeckoProfile.get(getContext(), "webapp" + index);
         return profile.getDir();
     }
 
+    // The old implementation of postInstallWebApp.  Not used by MOZ_ANDROID_SYNTHAPKS.
     public static void postInstallWebApp(String aTitle, String aURI, String aOrigin, String aIconURL, String aOriginalOrigin) {
-    	WebAppAllocator allocator = WebAppAllocator.getInstance(getContext());
-		int index = allocator.getIndexForApp(aOriginalOrigin);
-    	assert index != -1 && aIconURL != null;
-    	allocator.updateAppAllocation(aOrigin, index, BitmapUtils.getBitmapFromDataURI(aIconURL));
-    	createShortcut(aTitle, aURI, aOrigin, aIconURL, "webapp");
+        WebAppAllocator allocator = WebAppAllocator.getInstance(getContext());
+        int index = allocator.getIndexForApp(aOriginalOrigin);
+        assert index != -1 && aIconURL != null;
+        allocator.updateAppAllocation(aOrigin, index, BitmapUtils.getBitmapFromDataURI(aIconURL));
+        createShortcut(aTitle, aURI, aOrigin, aIconURL, "webapp");
+    }
+
+    // The new implementation of postInstallWebApp.  Used by MOZ_ANDROID_SYNTHAPKS.
+    public static void postInstallWebApp(String aPackageName, String aOrigin) {
+        org.mozilla.gecko.webapp.WebAppAllocator allocator = org.mozilla.gecko.webapp.WebAppAllocator.getInstance(getContext());
+        int index = allocator.findOrAllocatePackage(aPackageName);
+        allocator.putOrigin(index, aOrigin);
     }
 
     public static Intent getWebAppIntent(String aURI, String aOrigin, String aTitle, Bitmap aIcon) {
-        int index;
-        if (aIcon != null && !TextUtils.isEmpty(aTitle))
-            index = WebAppAllocator.getInstance(getContext()).findAndAllocateIndex(aOrigin, aTitle, aIcon);
-        else
-            index = WebAppAllocator.getInstance(getContext()).getIndexForApp(aOrigin);
-
-        if (index == -1)
-            return null;
-
-        return getWebAppIntent(index, aURI);
+        Intent intent;
+
+        if (AppConstants.MOZ_ANDROID_SYNTHAPKS) {
+            org.mozilla.gecko.webapp.WebAppAllocator slots = org.mozilla.gecko.webapp.WebAppAllocator.getInstance(getContext());
+            int index = slots.getIndexForOrigin(aOrigin);
+
+            if (index == -1) {
+                return null;
+            }
+            String packageName = slots.getAppForIndex(index);
+            intent = getContext().getPackageManager().getLaunchIntentForPackage(packageName);
+            if (aURI != null) {
+                intent.setData(Uri.parse(aURI));
+            }
+        } else {
+            int index;
+            if (aIcon != null && !TextUtils.isEmpty(aTitle))
+                index = WebAppAllocator.getInstance(getContext()).findAndAllocateIndex(aOrigin, aTitle, aIcon);
+            else
+                index = WebAppAllocator.getInstance(getContext()).getIndexForApp(aOrigin);
+
+            if (index == -1)
+                return null;
+
+            intent = getWebAppIntent(index, aURI);
+        }
+
+        return intent;
     }
 
+    // The old implementation of getWebAppIntent.  Not used by MOZ_ANDROID_SYNTHAPKS.
     public static Intent getWebAppIntent(int aIndex, String aURI) {
         Intent intent = new Intent();
         intent.setAction(GeckoApp.ACTION_WEBAPP_PREFIX + aIndex);
         intent.setData(Uri.parse(aURI));
         intent.setClassName(AppConstants.ANDROID_PACKAGE_NAME,
                             AppConstants.ANDROID_PACKAGE_NAME + ".WebApps$WebApp" + aIndex);
         return intent;
     }
@@ -798,17 +835,16 @@ public class GeckoAppShell
 
     public static void removeShortcut(final String aTitle, final String aURI, final String aUniqueURI, final String aType) {
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
                 // the intent to be launched by the shortcut
                 Intent shortcutIntent;
                 if (aType.equalsIgnoreCase(SHORTCUT_TYPE_WEBAPP)) {
-                    int index = WebAppAllocator.getInstance(getContext()).getIndexForApp(aUniqueURI);
                     shortcutIntent = getWebAppIntent(aURI, aUniqueURI, "", null);
                     if (shortcutIntent == null)
                         return;
                 } else {
                     shortcutIntent = new Intent();
                     shortcutIntent.setAction(GeckoApp.ACTION_BOOKMARK);
                     shortcutIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME,
                                                 AppConstants.BROWSER_INTENT_CLASS);
@@ -830,17 +866,22 @@ public class GeckoAppShell
 
     public static void uninstallWebApp(final String uniqueURI) {
         // On uninstall, we need to do a couple of things:
         //   1. nuke the running app process.
         //   2. nuke the profile that was assigned to that webapp
         ThreadUtils.postToBackgroundThread(new Runnable() {
             @Override
             public void run() {
-                int index = WebAppAllocator.getInstance(getContext()).releaseIndexForApp(uniqueURI);
+                int index;
+                if (AppConstants.MOZ_ANDROID_SYNTHAPKS) {
+                    index = org.mozilla.gecko.webapp.WebAppAllocator.getInstance(getContext()).releaseIndexForApp(uniqueURI);
+                } else {
+                    index = WebAppAllocator.getInstance(getContext()).releaseIndexForApp(uniqueURI);
+                }
 
                 // if -1, nothing to do; we didn't think it was installed anyway
                 if (index == -1)
                     return;
 
                 // kill the app if it's running
                 String targetProcessName = getContext().getPackageName();
                 targetProcessName = targetProcessName + ":" + targetProcessName + ".WebApp" + index;
@@ -2676,9 +2717,60 @@ public class GeckoAppShell
             case HTTP:
                 return "PROXY " + proxy.address().toString();
             case SOCKS:
                 return "SOCKS " + proxy.address().toString();
         }
 
         return "DIRECT";
     }
+
+    public static void installApk(final Activity context, String filePath, String data) {
+        // This is the data that mozApps.install sent to Webapps.jsm.
+        JSONObject argsObj = null;
+
+        // We get the manifest url out of javascript here so we can use it as a checksum
+        // in a minute, when a package has been installed.
+        String manifestUrl = null;
+        try {
+            argsObj = new JSONObject(data);
+            manifestUrl = argsObj.getJSONObject("app").getString("manifestURL");
+        } catch (JSONException e) {
+            Log.e(LOGTAG, "can't get manifest URL from JSON data", e);
+            // TODO: propagate the error back to the mozApps.install caller.
+            return;
+        }
+
+        // We will check the manifestUrl from the one in the APK.
+        // Thus, we can have a one-to-one mapping of apk to receiver.
+        final InstallListener receiver = new InstallListener(manifestUrl, argsObj);
+
+        // Listen for packages being installed.
+        IntentFilter filter = new IntentFilter(Intent.ACTION_PACKAGE_ADDED);
+        filter.addDataScheme("package");
+        context.registerReceiver(receiver, filter);
+
+        // Now call the package installer.
+        File file = new File(filePath);
+        Intent intent = new Intent(Intent.ACTION_VIEW);
+        intent.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive");
+
+        sActivityHelper.startIntentForActivity(context, intent, new ActivityResultHandler() {
+            @Override
+            public void onActivityResult(int resultCode, Intent data) {
+                // The InstallListener will catch the case where the user pressed install.
+                // Now deal with if the user pressed cancel.
+                if (resultCode == Activity.RESULT_CANCELED) {
+                    try {
+                        context.unregisterReceiver(receiver);
+                        receiver.cleanup();
+                    } catch (java.lang.IllegalArgumentException e) {
+                        // IllegalArgumentException happens because resultCode is RESULT_CANCELED
+                        // when the user presses the Done button in the install confirmation dialog,
+                        // even though the install has been successful (and InstallListener already
+                        // unregistered the receiver).
+                        Log.e(LOGTAG, "error unregistering install receiver: ", e);
+                    }
+                }
+            }
+        });
+    }
 }
--- a/mobile/android/base/GeckoProfile.java
+++ b/mobile/android/base/GeckoProfile.java
@@ -542,31 +542,39 @@ public final class GeckoProfile {
             throw new IOException("Unable to create profile.");
         }
         Log.d(LOGTAG, "Created new profile dir.");
 
         // Now update profiles.ini
         // If this is the first time its created, we also add a General section
         // look for the first profile number that isn't taken yet
         int profileNum = 0;
-        while (parser.getSection("Profile" + profileNum) != null) {
+        boolean isDefaultSet = false;
+        INISection profileSection;
+        while ((profileSection = parser.getSection("Profile" + profileNum)) != null) {
             profileNum++;
+            if (profileSection.getProperty("Default") != null) {
+                isDefaultSet = true;
+            }
         }
 
-        INISection profileSection = new INISection("Profile" + profileNum);
+        profileSection = new INISection("Profile" + profileNum);
         profileSection.setProperty("Name", mName);
         profileSection.setProperty("IsRelative", 1);
         profileSection.setProperty("Path", saltedName);
 
         if (parser.getSection("General") == null) {
             INISection generalSection = new INISection("General");
             generalSection.setProperty("StartWithLastProfile", 1);
             parser.addSection(generalSection);
+        }
 
-            // only set as default if this is the first profile we're creating
+        if (!isDefaultSet && !mName.startsWith("webapp")) {
+            // only set as default if this is the first non-webapp
+            // profile we're creating
             profileSection.setProperty("Default", 1);
         }
 
         parser.addSection(profileSection);
         parser.write();
 
         // Write out profile creation time, mirroring the logic in nsToolkitProfileService.
         try {
--- a/mobile/android/base/Makefile.in
+++ b/mobile/android/base/Makefile.in
@@ -37,16 +37,17 @@ DEFINES += \
   -DMOZ_ANDROID_SHARED_FXACCOUNT_TYPE="$(MOZ_ANDROID_SHARED_FXACCOUNT_TYPE)" \
   -DMOZ_APP_BUILDID=$(MOZ_APP_BUILDID) \
   -DMOZ_BUILD_TIMESTAMP=$(MOZ_BUILD_TIMESTAMP) \
   -DUA_BUILDID=$(UA_BUILDID) \
   $(NULL)
 
 GARBAGE += \
   AndroidManifest.xml  \
+  WebAppManifestFragment.xml.frag \
   classes.dex  \
   gecko.ap_  \
   res/values/strings.xml \
   .aapt.deps \
   fennec_ids.txt \
   javah.out \
   jni-stubs.inc \
   GeneratedJNIWrappers.cpp \
@@ -126,16 +127,17 @@ android_package_dir = $(addprefix genera
 
 # These _PP_JAVAFILES are specified in moz.build and defined in
 # backend.mk, which is included by config.mk.  Therefore this needs to
 # be defined after config.mk is included.
 PP_JAVAFILES := $(filter-out $(gecko_package_dir)/R.java,$(gecko-mozglue_PP_JAVAFILES) $(gecko-browser_PP_JAVAFILES))
 
 manifest := \
   AndroidManifest.xml.in \
+  WebAppManifestFragment.xml.frag.in \
   $(NULL)
 
 PP_TARGETS += manifest
 
 # Certain source files need to be preprocessed.  This special rule
 # generates these files into generated/org/mozilla/gecko for
 # consumption by the build system and IDEs.
 
@@ -177,16 +179,17 @@ android_res_files := $(wildcard $(addsuf
 # makefile, and trying to work around the lack of information, we
 # force a rebuild of gecko.ap_ during packaging.  See below.
 
 res/values/strings.xml: FORCE
 	$(MAKE) -C locales
 
 all_resources = \
   $(CURDIR)/AndroidManifest.xml \
+  $(CURDIR)/WebAppManifestFragment.xml.frag \
   $(android_res_files) \
   $(ANDROID_GENERATED_RESFILES) \
   $(NULL)
 
 # All of generated/org/mozilla/gecko/R.java, gecko.ap_, and R.txt are
 # produced by aapt; this saves aapt invocations.
 
 $(gecko_package_dir)/R.java: .aapt.deps
--- a/mobile/android/base/WebApp.java.in
+++ b/mobile/android/base/WebApp.java.in
@@ -1,15 +1,18 @@
 /* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; 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/. */
 
 #filter substitution
 package @ANDROID_PACKAGE_NAME@;
 
+#ifdef MOZ_ANDROID_SYNTHAPKS
+import org.mozilla.gecko.webapp.WebAppImpl;
+#else
 import org.mozilla.gecko.WebAppImpl;
+#endif
 
 /**
  * This class serves only as a namespace wrapper for WebAppImpl.
  */
 public class WebApp extends WebAppImpl {}
-
--- a/mobile/android/base/WebAppAllocator.java
+++ b/mobile/android/base/WebAppAllocator.java
@@ -5,19 +5,22 @@
 
 package org.mozilla.gecko;
 
 import org.mozilla.gecko.gfx.BitmapUtils;
 import org.mozilla.gecko.util.ThreadUtils;
 
 import android.content.Context;
 import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
 import android.graphics.Bitmap;
 import android.util.Log;
 
+import java.util.ArrayList;
+
 public class WebAppAllocator {
     private final String LOGTAG = "GeckoWebAppAllocator";
     // The number of WebApp# and WEBAPP# activites/apps/intents
     private final static int MAX_WEB_APPS = 100;
 
     protected static WebAppAllocator sInstance = null;
     public static WebAppAllocator getInstance() {
         return getInstance(GeckoAppShell.getContext());
deleted file mode 100644
--- a/mobile/android/base/WebAppManifestFragment.xml.frag
+++ /dev/null
@@ -1,17 +0,0 @@
-        <activity android:name=".WebApps$WebApp@APPNUM@"
-                  android:label="@string/webapp_generic_name"
-                  android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize"
-                  android:windowSoftInputMode="stateUnspecified|adjustResize"
-                  android:launchMode="singleTask"
-                  android:taskAffinity="org.mozilla.gecko.WEBAPP@APPNUM@"
-                  android:process=":@ANDROID_PACKAGE_NAME@.WebApp@APPNUM@"
-                  android:excludeFromRecents="true"
-                  android:theme="@style/Gecko.App">
-            <intent-filter>
-                <action android:name="org.mozilla.gecko.WEBAPP@APPNUM@" />
-            </intent-filter>
-            <intent-filter>
-                <action android:name="org.mozilla.gecko.ACTION_ALERT_CALLBACK" />
-            </intent-filter>
-        </activity>
-
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/WebAppManifestFragment.xml.frag.in
@@ -0,0 +1,22 @@
+        <activity android:name=".WebApps$WebApp@APPNUM@"
+                  android:label="@string/webapp_generic_name"
+                  android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize"
+                  android:windowSoftInputMode="stateUnspecified|adjustResize"
+                  android:process=":@ANDROID_PACKAGE_NAME@.WebApp@APPNUM@"
+                  android:theme="@style/Gecko.App"
+#ifdef MOZ_ANDROID_SYNTHAPKS
+                  android:launchMode="singleTop"
+                  android:exported="true"
+        />
+#else
+                  android:launchMode="singleTask"
+                  android:taskAffinity="org.mozilla.gecko.WEBAPP@APPNUM@"
+                  android:excludeFromRecents="true">
+            <intent-filter>
+                <action android:name="org.mozilla.gecko.WEBAPP@APPNUM@" />
+            </intent-filter>
+            <intent-filter>
+                <action android:name="org.mozilla.gecko.ACTION_ALERT_CALLBACK" />
+            </intent-filter>
+        </activity>
+#endif
--- a/mobile/android/base/gfx/BitmapUtils.java
+++ b/mobile/android/base/gfx/BitmapUtils.java
@@ -8,16 +8,17 @@ package org.mozilla.gecko.gfx;
 import org.mozilla.gecko.util.ThreadUtils;
 import org.mozilla.gecko.util.GeckoJarReader;
 import org.mozilla.gecko.util.UiAsyncTask;
 
 import android.content.Context;
 import android.content.res.Resources;
 import android.graphics.Bitmap;
 import android.graphics.BitmapFactory;
+import android.graphics.Canvas;
 import android.graphics.Color;
 import android.graphics.drawable.Drawable;
 import android.graphics.drawable.BitmapDrawable;
 import android.net.Uri;
 import android.os.AsyncTask;
 import android.util.Base64;
 import android.util.Log;
 import android.text.TextUtils;
@@ -289,16 +290,34 @@ public final class BitmapUtils {
             byte[] raw = Base64.decode(base64, Base64.DEFAULT);
             return BitmapUtils.decodeByteArray(raw);
         } catch (Exception e) {
             Log.e(LOGTAG, "exception decoding bitmap from data URI: " + dataURI, e);
         }
         return null;
     }
 
+    public static Bitmap getBitmapFromDrawable(Drawable drawable) {
+        if (drawable instanceof BitmapDrawable) {
+            return ((BitmapDrawable) drawable).getBitmap();
+        }
+
+        int width = drawable.getIntrinsicWidth();
+        width = width > 0 ? width : 1;
+        int height = drawable.getIntrinsicHeight();
+        height = height > 0 ? height : 1;
+
+        Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+        Canvas canvas = new Canvas(bitmap);
+        drawable.setBounds(0, 0, canvas.getWidth(), canvas.getHeight());
+        drawable.draw(canvas);
+
+        return bitmap;
+    }
+
     public static int getResource(Uri resourceUrl, int defaultIcon) {
         int icon = defaultIcon;
 
         final String scheme = resourceUrl.getScheme();
         if ("drawable".equals(scheme)) {
             String resource = resourceUrl.getSchemeSpecificPart();
             resource = resource.substring(resource.lastIndexOf('/') + 1);
 
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -314,16 +314,23 @@ gbjar.sources += [
     'toolbar/SiteIdentityPopup.java',
     'toolbar/TabCounter.java',
     'toolbar/ToolbarEditLayout.java',
     'toolbar/ToolbarEditText.java',
     'TouchEventInterceptor.java',
     'updater/UpdateService.java',
     'updater/UpdateServiceHelper.java',
     'VideoPlayer.java',
+    'webapp/ApkResources.java',
+    'webapp/InstallHelper.java',
+    'webapp/InstallListener.java',
+    'webapp/UninstallListener.java',
+    'webapp/WebAppAllocator.java',
+    'webapp/WebAppDispatcher.java',
+    'webapp/WebAppImpl.java',
     'WebAppAllocator.java',
     'WebAppImpl.java',
     'widget/ActivityChooserModel.java',
     'widget/AllCapsTextView.java',
     'widget/AnimatedHeightLayout.java',
     'widget/ArrowPopup.java',
     'widget/BasicColorPicker.java',
     'widget/ButtonToast.java',
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/webapp/ApkResources.java
@@ -0,0 +1,130 @@
+/* -*- 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.webapp;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import android.content.Context;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.graphics.drawable.Drawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.Environment;
+import android.util.Log;
+
+public class ApkResources {
+    private static final String LOGTAG = "GeckoApkResources";
+    private final String mPackageName;
+    private final ApplicationInfo mInfo;
+    private final Context mContext;
+
+    public ApkResources(Context context, String packageName) throws NameNotFoundException {
+        mPackageName = packageName;
+        mInfo = context.getPackageManager().getApplicationInfo(
+                    mPackageName, PackageManager.GET_META_DATA);
+        mContext = context;
+    }
+
+    private ApplicationInfo info() {
+        return mInfo;
+    }
+
+    public String getPackageName() {
+        return mPackageName;
+    }
+
+    private Bundle metadata() {
+        return mInfo.metaData;
+    }
+
+    public String getManifest(Context context) {
+        return readResource(context, "manifest");
+    }
+
+    public String getMiniManifest(Context context) {
+        return readResource(context, "mini");
+    }
+
+    public String getManifestUrl() {
+        return metadata().getString("manifestUrl");
+    }
+
+    public boolean isPackaged() {
+        return "packaged".equals(getWebAppType());
+    }
+
+    private String readResource(Context context, String resourceName) {
+        Uri resourceUri = Uri.parse("android.resource://" + mPackageName
+                + "/raw/" + resourceName);
+        StringBuilder fileContent = new StringBuilder();
+        try {
+            BufferedReader r = new BufferedReader(new InputStreamReader(context
+                    .getContentResolver().openInputStream(resourceUri)));
+            String line;
+
+            while ((line = r.readLine()) != null) {
+                fileContent.append(line);
+            }
+        } catch (FileNotFoundException e) {
+            Log.e(LOGTAG, String.format("File not found: \"%s\"", resourceName));
+        } catch (IOException e) {
+            Log.e(LOGTAG, String.format("Couldn't read file: \"%s\"", resourceName));
+        }
+
+        return fileContent.toString();
+    }
+
+    public Uri getAppIconUri() {
+        return Uri.parse("android.resource://" + mPackageName + "/" + info().icon);
+    }
+
+    public Drawable getAppIcon() {
+        return info().loadIcon(mContext.getPackageManager());
+    }
+
+    public String getWebAppType() {
+        return metadata().getString("webapp");
+    }
+
+    public String getAppName() {
+        return info().name;
+    }
+
+    /**
+     * Which APK installer installed this APK.
+     *
+     * For OEM backed marketplaces, this will be non-<code>null</code>. Otherwise, <code>null</code>.
+     *
+     * TODO check that the G+ package installer gives us non-null results.
+     *
+     * @return the package name of the APK that installed this.
+     */
+    public String getPackageInstallerName() {
+        return mContext.getPackageManager().getInstallerPackageName(mPackageName);
+    }
+
+    public Uri getZipFileUri() {
+        return Uri.parse("android.resource://" + mPackageName + "/raw/application");
+    }
+
+    public File getFileDirectory() {
+        File dir = mContext.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS);
+        String path = dir.getAbsolutePath().replace(mContext.getPackageName(), mPackageName);
+
+        dir = new File(path);
+
+        if (!dir.exists()) {
+            dir.mkdirs();
+        }
+
+        return dir;
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/webapp/InstallHelper.java
@@ -0,0 +1,175 @@
+/* -*- 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.webapp;
+
+import java.io.Closeable;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+import org.mozilla.gecko.GeckoProfile;
+import org.mozilla.gecko.gfx.BitmapUtils;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.util.Log;
+
+public class InstallHelper implements GeckoEventListener {
+    private static final String LOGTAG = "GeckoInstallHelper";
+    private static final String[] INSTALL_EVENT_NAMES = new String[] {"WebApps:PostInstall"};
+    private final Context mContext;
+    private final InstallCallback mCallback;
+    private final ApkResources mApkResources;
+
+    public static interface InstallCallback {
+        // on the GeckoThread
+        void installCompleted(InstallHelper installHelper, String event, JSONObject message);
+
+        // on the GeckoBackgroundThread
+        void installErrored(InstallHelper installHelper, Exception exception);
+    }
+
+    public InstallHelper(Context context, ApkResources apkResources, InstallCallback cb) {
+        mContext = context;
+        mCallback = cb;
+        mApkResources = apkResources;
+    }
+
+    public void startInstall(String profileName) throws IOException {
+        startInstall(profileName, null);
+    }
+
+    public void startInstall(final String profileName, final JSONObject message) throws IOException {
+        ThreadUtils.postToBackgroundThread(new Runnable() {
+            @Override
+            public void run() {
+                try {
+                    install(profileName, message);
+                } catch (IOException e) {
+                    handleException(e);
+                }
+            }
+        });
+    }
+
+    protected void handleException(Exception e) {
+        if (mCallback != null) {
+            mCallback.installErrored(this, e);
+        } else {
+            Log.e(LOGTAG, "mozApps.install failed", e);
+        }
+    }
+
+    private void install(String profileName, JSONObject message) throws IOException {
+        if (message == null) {
+            message = new JSONObject();
+        }
+
+        // we can change the profile to be in the app's area here
+        GeckoProfile profile = GeckoProfile.get(mContext, profileName);
+
+        try {
+            message.put("packageName", mApkResources.getPackageName());
+            message.put("manifestUrl", mApkResources.getManifestUrl());
+            message.put("title", mApkResources.getAppName());
+            message.put("manifest", new JSONObject(mApkResources.getManifest(mContext)));
+
+            String appType = mApkResources.getWebAppType();
+            message.putOpt("type", appType);
+            if ("packaged".equals(appType)) {
+                message.putOpt("updateManifest", new JSONObject(mApkResources.getMiniManifest(mContext)));
+            }
+
+            message.putOpt("profilePath", profile.getDir());
+
+            if (mApkResources.isPackaged()) {
+                File zipFile = copyApplicationZipFile();
+                message.putOpt("zipFilePath", Uri.fromFile(zipFile).toString());
+            }
+        } catch (JSONException e) {
+            handleException(e);
+            return;
+        }
+
+        registerGeckoListener();
+
+        GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Webapps:AutoInstall", message.toString()));
+        calculateColor();
+    }
+
+    public File copyApplicationZipFile() throws IOException {
+        if (!mApkResources.isPackaged()) {
+            return null;
+        }
+
+        Uri uri = mApkResources.getZipFileUri();
+
+        InputStream in = null;
+        OutputStream out = null;
+        File destPath = new File(mApkResources.getFileDirectory(), "application.zip");
+        try {
+            in = mContext.getContentResolver().openInputStream(uri);
+            out = new FileOutputStream(destPath);
+            byte[] buffer = new byte[1024];
+            int read = 0;
+            while ((read = in.read(buffer)) != -1) {
+                out.write(buffer, 0, read);
+            }
+            out.flush();
+        } catch (IOException e) {
+            throw e;
+        } finally {
+            close(in);
+            close(out);
+        }
+        return destPath;
+    }
+
+    private static void close(Closeable close) {
+        if (close == null) {
+            return;
+        }
+        try {
+            close.close();
+        } catch (IOException e) {
+            // NOP
+        }
+    }
+
+    public void registerGeckoListener() {
+        for (String eventName : INSTALL_EVENT_NAMES) {
+            GeckoAppShell.registerEventListener(eventName, this);
+        }
+    }
+
+    private void calculateColor() {
+        ThreadUtils.assertOnBackgroundThread();
+        WebAppAllocator slots = WebAppAllocator.getInstance(mContext);
+        int index = slots.getIndexForApp(mApkResources.getPackageName());
+        Bitmap bitmap = BitmapUtils.getBitmapFromDrawable(mApkResources.getAppIcon());
+        slots.updateColor(index, BitmapUtils.getDominantColor(bitmap));
+    }
+
+    @Override
+    public void handleMessage(String event, JSONObject message) {
+        for (String eventName : INSTALL_EVENT_NAMES) {
+            GeckoAppShell.unregisterEventListener(eventName, this);
+        }
+
+        if (mCallback != null) {
+            mCallback.installCompleted(this, event, message);
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/webapp/InstallListener.java
@@ -0,0 +1,105 @@
+/* -*- 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.webapp;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoThread;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.os.Environment;
+import android.text.TextUtils;
+import android.util.Log;
+
+public class InstallListener extends BroadcastReceiver {
+
+    private static String LOGTAG = "GeckoInstallListener";
+    private JSONObject mData = null;
+    private String mManifestUrl;
+
+    public InstallListener(String manifestUrl, JSONObject data) {
+        mData = data;
+        mManifestUrl = manifestUrl;
+        assert mManifestUrl != null;
+    }
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        String packageName = intent.getData().getSchemeSpecificPart();
+
+        if (TextUtils.isEmpty(packageName)) {
+            Log.i(LOGTAG, "No package name defined in intent");
+            return;
+        }
+
+        ApkResources apkResources = null;
+        try {
+            apkResources = new ApkResources(context, packageName);
+        } catch (NameNotFoundException e) {
+            Log.e(LOGTAG, "Can't find package that's just been installed");
+            return;
+        }
+
+        String manifestUrl = apkResources.getManifestUrl();
+        if (TextUtils.isEmpty(manifestUrl)) {
+            Log.i(LOGTAG, "No manifest URL present in metadata");
+            return;
+        } else if (!isCorrectManifest(manifestUrl)) {
+            Log.i(LOGTAG, "Waiting to finish installing " + mManifestUrl + " but this is " +manifestUrl);
+            //return;
+        }
+
+        if (GeckoThread.checkLaunchState(GeckoThread.LaunchState.GeckoRunning)) {
+            InstallHelper installHelper = new InstallHelper(context, apkResources, null);
+            try {
+                JSONObject dataObject = mData;
+                dataObject = new JSONObject().put("request", dataObject);
+                WebAppAllocator slots = WebAppAllocator.getInstance(context);
+                int i = slots.findOrAllocatePackage(packageName);
+                installHelper.startInstall("webapp" + i, dataObject);
+            } catch (JSONException e) {
+                Log.e(LOGTAG, "Couldn't parse data from mozApps.install()", e);
+            } catch (IOException e) {
+                Log.e(LOGTAG, "Couldn't install packaged app", e);
+            }
+        }
+
+        cleanup();
+
+        // we don't need this anymore.
+        context.unregisterReceiver(this);
+    }
+
+    public boolean isCorrectManifest(String manifestUrl) {
+        try {
+            URL registered = new URL(mManifestUrl);
+            URL observed = new URL(manifestUrl);
+            // TODO: this should be matching the scheme, origin and path, but ignoring the query.
+            // That doesn't seem to be happening.
+            return registered.sameFile(observed);
+        } catch (MalformedURLException e) {
+            return false;
+        }
+    }
+
+    public void cleanup() {
+        String manifestUrlFilename = mManifestUrl.replaceAll("[^a-zA-Z0-9]", "");
+
+        File apkFile = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), manifestUrlFilename + ".apk");
+        if (apkFile.exists()) {
+            apkFile.delete();
+            Log.i(LOGTAG, "Downloaded APK file deleted");
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/webapp/UninstallListener.java
@@ -0,0 +1,94 @@
+/* -*- 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.webapp;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.json.JSONArray;
+
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.ArrayList;
+
+public class UninstallListener extends BroadcastReceiver {
+
+    private static String LOGTAG = "GeckoUninstallListener";
+
+    @Override
+    public void onReceive(Context context, Intent intent) {
+        String packageName = intent.getData().getSchemeSpecificPart();
+
+        if (TextUtils.isEmpty(packageName)) {
+            Log.i(LOGTAG, "No package name defined in intent");
+            return;
+        }
+
+        WebAppAllocator allocator = WebAppAllocator.getInstance(context);
+        ArrayList<String> installedPackages = allocator.getInstalledPackageNames();
+
+        if (installedPackages.contains(packageName)) {
+            JSONObject message = new JSONObject();
+            JSONArray packageNames = new JSONArray();
+            try {
+                packageNames.put(packageName);
+                message.put("packages", packageNames);
+                GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Webapps:AutoUninstall", message.toString()));
+            } catch (JSONException e) {
+                Log.e(LOGTAG, "JSON EXCEPTION " + e);
+            }
+        }
+    }
+
+    public static void initUninstallPackageScan(Context context) {
+        // get list of packages we think are installed
+        WebAppAllocator allocator = WebAppAllocator.getInstance(context);
+        ArrayList<String> fennecPackages = allocator.getInstalledPackageNames();
+        ArrayList<String> uninstalledPackages = new ArrayList<String>();
+
+        final PackageManager pm = context.getPackageManager();
+        //get a list of installed apps on device
+        List<ApplicationInfo> packages = pm.getInstalledApplications(PackageManager.GET_META_DATA);
+        Set<String> allInstalledPackages = new HashSet<String>();
+
+        for (ApplicationInfo packageInfo : packages) {
+            //Log.i(LOGTAG, "Android package: " + packageInfo.packageName);
+            allInstalledPackages.add(packageInfo.packageName);
+        }
+
+        for (String packageName : fennecPackages) {
+            if (!allInstalledPackages.contains(packageName)) {
+                uninstalledPackages.add(packageName);
+            }
+        }
+
+        if (uninstalledPackages.size() > 0) {
+            JSONObject message = new JSONObject();
+            JSONArray packageNames = new JSONArray();
+            try {
+                for (String packageName : uninstalledPackages) {
+                    packageNames.put(packageName);
+                }
+                message.put("packages", packageNames);
+                GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Webapps:AutoUninstall", message.toString()));
+            } catch (JSONException e) {
+                Log.e(LOGTAG, "JSON EXCEPTION " + e);
+            }
+        }
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/webapp/WebAppAllocator.java
@@ -0,0 +1,145 @@
+/* -*- 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.webapp;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.util.ThreadUtils;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+
+import java.util.ArrayList;
+
+import android.util.Log;
+
+public class WebAppAllocator {
+
+    private final String LOGTAG = "GeckoWebAppAllocator";
+
+    private static final String PREFIX_ORIGIN = "webapp-origin-";
+    private static final String PREFIX_PACKAGE_NAME = "webapp-package-name-";
+
+    // The number of WebApp# and WEBAPP# activites/apps/intents
+    private final static int MAX_WEB_APPS = 100;
+
+    protected static WebAppAllocator sInstance = null;
+    public static WebAppAllocator getInstance() {
+        return getInstance(GeckoAppShell.getContext());
+    }
+
+    public static synchronized WebAppAllocator getInstance(Context cx) {
+        if (sInstance == null) {
+            sInstance = new WebAppAllocator(cx);
+        }
+
+        return sInstance;
+    }
+
+    SharedPreferences mPrefs;
+
+    protected WebAppAllocator(Context context) {
+        mPrefs = context.getSharedPreferences("webapps", Context.MODE_PRIVATE | Context.MODE_MULTI_PROCESS);
+    }
+
+    private static String appKey(int index) {
+        return PREFIX_PACKAGE_NAME + index;
+    }
+
+    public static String iconKey(int index) {
+        return "web-app-color-" + index;
+    }
+
+    public static String originKey(int i) {
+        return PREFIX_ORIGIN + i;
+    }
+
+    public ArrayList<String> getInstalledPackageNames() {
+        ArrayList<String> installedPackages = new ArrayList<String>();
+
+        for (int i = 0; i < MAX_WEB_APPS; ++i) {
+            if (mPrefs.contains(appKey(i))) {
+                installedPackages.add(mPrefs.getString(appKey(i), ""));
+            }
+        }
+        return installedPackages;
+    }
+
+    public synchronized int findOrAllocatePackage(final String packageName) {
+        int index = getIndexForApp(packageName);
+        if (index != -1)
+            return index;
+
+        for (int i = 0; i < MAX_WEB_APPS; ++i) {
+            if (!mPrefs.contains(appKey(i))) {
+                // found unused index i
+                putPackageName(i, packageName);
+                return i;
+            }
+        }
+
+        // no more apps!
+        return -1;
+    }
+
+    public synchronized void putPackageName(final int index, final String packageName) {
+        mPrefs.edit().putString(appKey(index), packageName).apply();
+    }
+
+    public void updateColor(int index, int color) {
+        mPrefs.edit().putInt(iconKey(index), color).apply();
+    }
+
+    public synchronized int getIndexForApp(String packageName) {
+        return findSlotForPrefix(PREFIX_PACKAGE_NAME, packageName);
+    }
+
+    public synchronized int getIndexForOrigin(String origin) {
+        return findSlotForPrefix(PREFIX_ORIGIN, origin);
+    }
+
+    protected int findSlotForPrefix(String prefix, String value) {
+        for (int i = 0; i < MAX_WEB_APPS; ++i) {
+            if (mPrefs.getString(prefix + i, "").equals(value)) {
+                return i;
+            }
+        }
+        return -1;
+    }
+
+    public synchronized String getAppForIndex(int index) {
+        return mPrefs.getString(appKey(index), null);
+    }
+
+    public synchronized int releaseIndexForApp(String app) {
+        int index = getIndexForApp(app);
+        if (index == -1)
+            return -1;
+
+        releaseIndex(index);
+        return index;
+    }
+
+    public synchronized void releaseIndex(final int index) {
+        mPrefs.edit()
+              .remove(appKey(index))
+              .remove(iconKey(index))
+              .remove(originKey(index))
+              .apply();
+    }
+
+    public void putOrigin(int index, String origin) {
+        mPrefs.edit().putString(originKey(index), origin).apply();
+    }
+
+    public String getOrigin(int index) {
+        return mPrefs.getString(originKey(index), null);
+    }
+
+    public int getColor(int index) {
+        return mPrefs.getInt(iconKey(index), -1);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/webapp/WebAppDispatcher.java
@@ -0,0 +1,44 @@
+/* -*- 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.webapp;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+
+public class WebAppDispatcher extends Activity {
+    private static final String LOGTAG = "GeckoWebAppDispatcher";
+
+    @Override
+    protected void onCreate(Bundle bundle) {
+        super.onCreate(bundle);
+
+        WebAppAllocator allocator = WebAppAllocator.getInstance(getApplicationContext());
+
+        if (bundle == null) {
+            bundle = getIntent().getExtras();
+        }
+
+        String packageName = bundle.getString("packageName");
+
+        int index = allocator.getIndexForApp(packageName);
+        boolean isInstalled = index >= 0;
+        if (!isInstalled) {
+            index = allocator.findOrAllocatePackage(packageName);
+        }
+
+        // Copy the intent, without interfering with it.
+        Intent intent = new Intent(getIntent());
+
+        // Only change it's destination.
+        intent.setClassName(getApplicationContext(), getPackageName() + ".WebApps$WebApp" + index);
+
+        // If and only if we haven't seen this before.
+        intent.putExtra("isInstalled", isInstalled);
+
+        startActivity(intent);
+    }
+}
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/webapp/WebAppImpl.java
@@ -0,0 +1,336 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 20; 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.webapp;
+
+import java.io.File;
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+import org.mozilla.gecko.GeckoApp;
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.GeckoEvent;
+import org.mozilla.gecko.GeckoThread;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.Tab;
+import org.mozilla.gecko.Tabs;
+import org.mozilla.gecko.webapp.ApkResources;
+import org.mozilla.gecko.webapp.InstallHelper;
+import org.mozilla.gecko.webapp.InstallHelper.InstallCallback;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.GradientDrawable;
+import android.net.Uri;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.Display;
+import android.view.View;
+import android.view.animation.Animation;
+import android.view.animation.AnimationUtils;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+public class WebAppImpl extends GeckoApp implements InstallCallback {
+    private static final String LOGTAG = "GeckoWebAppImpl";
+
+    private URL mOrigin;
+    private TextView mTitlebarText = null;
+    private View mTitlebar = null;
+
+    private View mSplashscreen;
+
+    private ApkResources mApkResources;
+
+    protected int getIndex() { return 0; }
+
+    @Override
+    public int getLayout() { return R.layout.web_app; }
+
+    @Override
+    public boolean hasTabsSideBar() { return false; }
+
+    @Override
+    public void onCreate(Bundle savedInstance)
+    {
+
+        String action = getIntent().getAction();
+        Bundle extras = getIntent().getExtras();
+        if (extras == null) {
+            extras = savedInstance;
+        }
+
+        if (extras == null) {
+            extras = new Bundle();
+        }
+
+        boolean isInstalled = extras.getBoolean("isInstalled", false);
+        String packageName = extras.getString("packageName");
+
+        if (packageName == null) {
+            // TODO Migration path!
+            Log.w(LOGTAG, "Can't find package name for webapp");
+            setResult(RESULT_CANCELED);
+            finish();
+        }
+
+        try {
+            mApkResources = new ApkResources(this, packageName);
+        } catch (NameNotFoundException e) {
+            Log.e(LOGTAG, "Can't find package for webapp " + packageName, e);
+            setResult(RESULT_CANCELED);
+            finish();
+        }
+
+        // start Gecko.
+        super.onCreate(savedInstance);
+
+        mTitlebarText = (TextView)findViewById(R.id.webapp_title);
+        mTitlebar = findViewById(R.id.webapp_titlebar);
+        mSplashscreen = findViewById(R.id.splashscreen);
+
+        String origin = WebAppAllocator.getInstance(this).getOrigin(getIndex());
+        boolean isInstallCompleting = (origin == null);
+
+        if (!GeckoThread.checkLaunchState(GeckoThread.LaunchState.GeckoRunning) || !isInstalled || isInstallCompleting) {
+            // Show the splash screen if we need to start Gecko, or we need to install this.
+            overridePendingTransition(R.anim.grow_fade_in_center, android.R.anim.fade_out);
+            showSplash(true);
+        } else {
+            mSplashscreen.setVisibility(View.GONE);
+        }
+
+        if (!isInstalled || isInstallCompleting) {
+            InstallHelper installHelper = new InstallHelper(getApplicationContext(), mApkResources, this);
+            if (!isInstalled) {
+                // start the vanilla install.
+                try {
+                    installHelper.startInstall(getDefaultProfileName());
+                } catch (IOException e) {
+                    Log.e(LOGTAG, "Couldn't install packaged app", e);
+                }
+            } else {
+                // an install is already happening, so we should let it complete.
+                Log.i(LOGTAG, "Waiting for existing install to complete");
+                installHelper.registerGeckoListener();
+            }
+            return;
+        } else {
+            launchWebApp(origin, mApkResources.getManifestUrl(), mApkResources.getAppName());
+        }
+
+        setTitle(mApkResources.getAppName());
+    }
+
+    @Override
+    protected String getURIFromIntent(Intent intent) {
+        String uri = super.getURIFromIntent(intent);
+        if (uri != null) {
+            return uri;
+        }
+        // This is where we construct the URL from the Intent from the
+        // the synthesized APK.
+
+        // TODO Translate AndroidIntents into WebActivities here.
+        return mApkResources.getManifestUrl();
+    }
+
+    @Override
+    protected void loadStartupTab(String uri) {
+        // NOP
+    }
+
+    private void showSplash(boolean isApk) {
+
+        // get the favicon dominant color, stored when the app was installed
+        int dominantColor = WebAppAllocator.getInstance().getColor(getIndex());
+
+        setBackgroundGradient(dominantColor);
+
+        ImageView image = (ImageView)findViewById(R.id.splashscreen_icon);
+        Drawable d = null;
+
+        if (isApk) {
+            Uri uri = mApkResources.getAppIconUri();
+            image.setImageURI(uri);
+            d = image.getDrawable();
+        } else {
+            // look for a logo.png in the profile dir and show it. If we can't find a logo show nothing
+            File profile = getProfile().getDir();
+            File logoFile = new File(profile, "logo.png");
+            if (logoFile.exists()) {
+                d = Drawable.createFromPath(logoFile.getPath());
+                image.setImageDrawable(d);
+            }
+        }
+
+        if (d != null) {
+            Animation fadein = AnimationUtils.loadAnimation(this, R.anim.grow_fade_in_center);
+            fadein.setStartOffset(500);
+            fadein.setDuration(1000);
+            image.startAnimation(fadein);
+        }
+    }
+
+    public void setBackgroundGradient(int dominantColor) {
+        int[] colors = new int[2];
+        // now lighten it, to ensure that the icon stands out in the center
+        float[] f = new float[3];
+        Color.colorToHSV(dominantColor, f);
+        f[2] = Math.min(f[2]*2, 1.0f);
+        colors[0] = Color.HSVToColor(255, f);
+
+        // now generate a second, slightly darker version of the same color
+        f[2] *= 0.75;
+        colors[1] = Color.HSVToColor(255, f);
+
+        // Draw the background gradient
+        GradientDrawable gd = new GradientDrawable(GradientDrawable.Orientation.TL_BR, colors);
+        gd.setGradientType(GradientDrawable.RADIAL_GRADIENT);
+        Display display = getWindowManager().getDefaultDisplay();
+        gd.setGradientCenter(0.5f, 0.5f);
+        gd.setGradientRadius(Math.max(display.getWidth()/2, display.getHeight()/2));
+        mSplashscreen.setBackgroundDrawable(gd);
+    }
+
+    /* (non-Javadoc)
+     * @see org.mozilla.gecko.GeckoApp#getDefaultProfileName()
+     */
+    @Override
+    protected String getDefaultProfileName() {
+        return "webapp" + getIndex();
+    }
+
+    @Override
+    protected boolean getSessionRestoreState(Bundle savedInstanceState) {
+        // for now webapps never restore your session
+        return false;
+    }
+
+    @Override
+    public void onTabChanged(Tab tab, Tabs.TabEvents msg, Object data) {
+        switch(msg) {
+            case SELECTED:
+            case LOCATION_CHANGE:
+                if (Tabs.getInstance().isSelectedTab(tab)) {
+                    final String urlString = tab.getURL();
+                    final URL url;
+
+                    try {
+                        url = new URL(urlString);
+                    } catch (java.net.MalformedURLException ex) {
+                        mTitlebarText.setText(urlString);
+
+                        // If we can't parse the url, and its an app protocol hide
+                        // the titlebar and return, otherwise show the titlebar
+                        // and the full url
+                        if (urlString != null && !urlString.startsWith("app://")) {
+                            mTitlebar.setVisibility(View.VISIBLE);
+                        } else {
+                            mTitlebar.setVisibility(View.GONE);
+                        }
+                        return;
+                    }
+
+                    if (mOrigin != null && mOrigin.getHost().equals(url.getHost())) {
+                        mTitlebar.setVisibility(View.GONE);
+                    } else {
+                        mTitlebarText.setText(url.getProtocol() + "://" + url.getHost());
+                        mTitlebar.setVisibility(View.VISIBLE);
+                    }
+                }
+                break;
+            case LOADED:
+                hideSplash();
+                break;
+            case START:
+                if (mSplashscreen != null && mSplashscreen.getVisibility() == View.VISIBLE) {
+                    View area = findViewById(R.id.splashscreen_progress);
+                    area.setVisibility(View.VISIBLE);
+                    Animation fadein = AnimationUtils.loadAnimation(this, android.R.anim.fade_in);
+                    fadein.setDuration(1000);
+                    area.startAnimation(fadein);
+                }
+                break;
+        }
+        super.onTabChanged(tab, msg, data);
+    }
+
+    protected void hideSplash() {
+        if (mSplashscreen != null && mSplashscreen.getVisibility() == View.VISIBLE) {
+            Animation fadeout = AnimationUtils.loadAnimation(this, android.R.anim.fade_out);
+            fadeout.setAnimationListener(new Animation.AnimationListener() {
+                @Override
+                public void onAnimationEnd(Animation animation) {
+                  mSplashscreen.setVisibility(View.GONE);
+                }
+                @Override
+                public void onAnimationRepeat(Animation animation) { }
+                @Override
+                public void onAnimationStart(Animation animation) { }
+            });
+            mSplashscreen.startAnimation(fadeout);
+        }
+    }
+
+    @Override
+    public void installCompleted(InstallHelper installHelper, String event, JSONObject message) {
+        if (event == null) {
+            return;
+        }
+
+        if (event.equals("WebApps:PostInstall")) {
+            String origin = message.optString("origin");
+            String manifestUrl = message.optString("manifestURL");
+            String name = message.optString("name", "WebApp");
+            launchWebApp(origin, manifestUrl, name);
+        }
+    }
+
+    @Override
+    public void installErrored(InstallHelper installHelper, Exception exception) {
+        Log.e(LOGTAG, "Install errored", exception);
+    }
+
+    public void launchWebApp(String origin, String manifestUrl, String name) {
+        try {
+            mOrigin = new URL(origin);
+        } catch (java.net.MalformedURLException ex) {
+            // If we can't parse the this is an app protocol, just settle for not having an origin
+            if (!origin.startsWith("app://")) {
+                return;
+            }
+
+            // If that failed fall back to the origin stored in the shortcut
+            Log.i(LOGTAG, "Webapp is not registered with allocator");
+            Uri data = getIntent().getData();
+            if (data != null) {
+                try {
+                    mOrigin = new URL(data.toString());
+                } catch (java.net.MalformedURLException ex2) {
+                    Log.e(LOGTAG, "Unable to parse intent url: ", ex);
+                }
+            }
+        }
+        try {
+            JSONObject launchObject = new JSONObject();
+            launchObject.putOpt("url", manifestUrl);
+            launchObject.putOpt("name", mApkResources.getAppName());
+            Log.i(LOGTAG, "Trying to launch: " + launchObject);
+            GeckoAppShell.sendEventToGecko(GeckoEvent.createBroadcastEvent("Webapps:Load", launchObject.toString()));
+        } catch (JSONException e) {
+            Log.e(LOGTAG, "Error populating launch message", e);
+        }
+    }
+}
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -65,22 +65,26 @@ XPCOMUtils.defineLazyServiceGetter(this,
                                    "@mozilla.org/uuid-generator;1",
                                    "nsIUUIDGenerator");
 
 #ifdef NIGHTLY_BUILD
 XPCOMUtils.defineLazyModuleGetter(this, "ShumwayUtils",
                                   "resource://shumway/ShumwayUtils.jsm");
 #endif
 
+#ifdef MOZ_ANDROID_SYNTHAPKS
+XPCOMUtils.defineLazyModuleGetter(this, "WebappManager",
+                                  "resource://gre/modules/WebappManager.jsm");
+#endif
+
 // Lazily-loaded browser scripts:
 [
   ["SelectHelper", "chrome://browser/content/SelectHelper.js"],
   ["InputWidgetHelper", "chrome://browser/content/InputWidgetHelper.js"],
   ["AboutReader", "chrome://browser/content/aboutReader.js"],
-  ["WebAppRT", "chrome://browser/content/WebAppRT.js"],
   ["MasterPassword", "chrome://browser/content/MasterPassword.js"],
   ["PluginHelper", "chrome://browser/content/PluginHelper.js"],
   ["OfflineApps", "chrome://browser/content/OfflineApps.js"],
   ["Linkifier", "chrome://browser/content/Linkify.js"],
 ].forEach(function (aScript) {
   let [name, script] = aScript;
   XPCOMUtils.defineLazyGetter(window, name, function() {
     let sandbox = {};
@@ -306,17 +310,25 @@ var BrowserApp = {
     Services.obs.addObserver(this, "FullScreen:Exit", false);
     Services.obs.addObserver(this, "Viewport:Change", false);
     Services.obs.addObserver(this, "Viewport:Flush", false);
     Services.obs.addObserver(this, "Viewport:FixedMarginsChanged", false);
     Services.obs.addObserver(this, "Passwords:Init", false);
     Services.obs.addObserver(this, "FormHistory:Init", false);
     Services.obs.addObserver(this, "gather-telemetry", false);
     Services.obs.addObserver(this, "keyword-search", false);
-
+#ifdef MOZ_ANDROID_SYNTHAPKS
+    Services.obs.addObserver(this, "webapps-download-apk", false);
+    Services.obs.addObserver(this, "webapps-ask-install", false);
+    Services.obs.addObserver(this, "webapps-launch", false);
+    Services.obs.addObserver(this, "webapps-uninstall", false);
+    Services.obs.addObserver(this, "Webapps:AutoInstall", false);
+    Services.obs.addObserver(this, "Webapps:Load", false);
+    Services.obs.addObserver(this, "Webapps:AutoUninstall", false);
+#endif
     Services.obs.addObserver(this, "sessionstore-state-purge-complete", false);
 
     function showFullScreenWarning() {
       NativeWindow.toast.show(Strings.browser.GetStringFromName("alertFullScreenToast"), "short");
     }
 
     window.addEventListener("fullscreen", function() {
       sendMessageToJava({
@@ -341,17 +353,26 @@ var BrowserApp = {
     LightWeightThemeWebInstaller.init();
     Downloads.init();
     FormAssistant.init();
     IndexedDB.init();
     HealthReportStatusListener.init();
     XPInstallObserver.init();
     CharacterEncoding.init();
     ActivityObserver.init();
+#ifdef MOZ_ANDROID_SYNTHAPKS
+    // TODO: replace with Android implementation of WebappOSUtils.isLaunchable.
+    Cu.import("resource://gre/modules/Webapps.jsm");
+    DOMApplicationRegistry.allAppsLaunchable = true;
+
+    // TODO: figure out why this is needed here.
+    Cu.import("resource://gre/modules/AppsUtils.jsm");
+#else
     WebappsUI.init();
+#endif
     RemoteDebugger.init();
     Reader.init();
     UserAgentOverrides.init();
     DesktopUserAgent.init();
     Distribution.init();
     Tabs.init();
 #ifdef ACCESSIBILITY
     AccessFu.attach(window);
@@ -375,19 +396,17 @@ var BrowserApp = {
       if (window.arguments[3])
         pinned = window.arguments[3];
       if (window.arguments[4])
         this.isGuest = window.arguments[4];
     }
 
     let status = this.startupStatus();
     if (pinned) {
-      WebAppRT.init(status, url, function(aUrl) {
-        BrowserApp.addTab(aUrl);
-      });
+      this._initRuntime(status, url, aUrl => this.addTab(aUrl));
     } else {
       SearchEngines.init();
       this.initContextMenu();
     }
     // The order that context menu items are added is important
     // Make sure the "Open in App" context menu item appears at the bottom of the list
     ExternalApps.init();
 
@@ -431,16 +450,23 @@ var BrowserApp = {
   /**
    * Pass this a locale string, such as "fr" or "es_ES".
    */
   setLocale: function (locale) {
     console.log("browser.js: requesting locale set: " + locale);
     sendMessageToJava({ type: "Locale:Set", locale: locale });
   },
 
+  _initRuntime: function(status, url, callback) {
+    let sandbox = {};
+    Services.scriptloader.loadSubScript("chrome://browser/content/WebAppRT.js", sandbox);
+    window.WebAppRT = sandbox.WebAppRT;
+    WebAppRT.init(status, url, callback);
+  },
+
   initContextMenu: function ba_initContextMenu() {
     // TODO: These should eventually move into more appropriate classes
     NativeWindow.contextmenus.add(Strings.browser.GetStringFromName("contextmenu.openInNewTab"),
       NativeWindow.contextmenus.linkOpenableNonPrivateContext,
       function(aTarget) {
         let url = NativeWindow.contextmenus._getLinkURL(aTarget);
         BrowserApp.addTab(url, { selected: false, parentId: BrowserApp.selectedTab.id });
 
@@ -674,17 +700,19 @@ var BrowserApp = {
     LightWeightThemeWebInstaller.uninit();
     FormAssistant.uninit();
     IndexedDB.uninit();
     ViewportHandler.uninit();
     XPInstallObserver.uninit();
     HealthReportStatusListener.uninit();
     CharacterEncoding.uninit();
     SearchEngines.uninit();
+#ifndef MOZ_ANDROID_SYNTHAPKS
     WebappsUI.uninit();
+#endif
     RemoteDebugger.uninit();
     Reader.uninit();
     UserAgentOverrides.uninit();
     DesktopUserAgent.uninit();
     ExternalApps.uninit();
     Distribution.uninit();
     Tabs.uninit();
   },
@@ -861,16 +889,26 @@ var BrowserApp = {
 
     let message = {
       type: "Tab:Close",
       tabID: aTab.id
     };
     sendMessageToJava(message);
   },
 
+#ifdef MOZ_ANDROID_SYNTHAPKS
+  _loadWebapp: function(aMessage) {
+    // TODO: figure out when (if ever) to pass "new" to the status parameter.
+    this._initRuntime("", aMessage.url, aUrl => {
+      this.manifestUrl = aMessage.url;
+      this.addTab(aUrl, { title: aMessage.name });
+    });
+  },
+#endif
+
   // Calling this will update the state in BrowserApp after a tab has been
   // closed in the Java UI.
   _handleTabClosed: function _handleTabClosed(aTab) {
     if (aTab == this.selectedTab)
       this.selectedTab = null;
 
     let evt = document.createEvent("UIEvents");
     evt.initUIEvent("TabClose", true, false, window, null);
@@ -1515,16 +1553,48 @@ var BrowserApp = {
         gViewportMargins = JSON.parse(aData);
         this.selectedTab.updateViewportSize(gScreenWidth);
         break;
 
       case "nsPref:changed":
         this.notifyPrefObservers(aData);
         break;
 
+#ifdef MOZ_ANDROID_SYNTHAPKS
+      case "webapps-download-apk":
+        WebappManager.downloadApk(JSON.parse(aData));
+        break;
+
+      case "webapps-ask-install":
+        WebappManager.askInstall(JSON.parse(aData));
+        break;
+
+      case "webapps-launch": {
+        WebappManager.launch(JSON.parse(aData));
+        break;
+      }
+
+      case "webapps-uninstall": {
+        WebappManager.uninstall(JSON.parse(aData));
+        break;
+      }
+
+      case "Webapps:AutoInstall":
+        WebappManager.autoInstall(JSON.parse(aData));
+        break;
+
+      case "Webapps:Load":
+        this._loadWebapp(JSON.parse(aData));
+        break;
+
+      case "Webapps:AutoUninstall":
+        WebappManager.autoUninstall(JSON.parse(aData));
+        break;
+#endif
+
       case "Locale:Changed":
         // The value provided to Locale:Changed should be a BCP47 language tag
         // understood by Gecko -- for example, "es-ES" or "de".
         console.log("Locale:Changed: " + aData);
 
         // TODO: do we need to be more nuanced here -- e.g., checking for the
         // OS locale -- or should it always be false on Fennec?
         Services.prefs.setBoolPref("intl.locale.matchOS", false);
@@ -6975,16 +7045,17 @@ var ActivityObserver = {
     }
 
     if (tab && tab.getActive() != isForeground) {
       tab.setActive(isForeground);
     }
   }
 };
 
+#ifndef MOZ_ANDROID_SYNTHAPKS
 var WebappsUI = {
   init: function init() {
     Cu.import("resource://gre/modules/Webapps.jsm");
     Cu.import("resource://gre/modules/AppsUtils.jsm");
     DOMApplicationRegistry.allAppsLaunchable = true;
 
     Services.obs.addObserver(this, "webapps-ask-install", false);
     Services.obs.addObserver(this, "webapps-launch", false);
@@ -7243,16 +7314,17 @@ var WebappsUI = {
         let shell = Cc["@mozilla.org/browser/shell-service;1"].createInstance(Ci.nsIShellService);
         shell.createShortcut(aTitle, aURL, icon, aType);
       } catch(e) {
         Cu.reportError(e);
       }
     });
   }
 }
+#endif
 
 var RemoteDebugger = {
   init: function rd_init() {
     Services.prefs.addObserver("devtools.debugger.", this, false);
 
     if (this._isEnabled())
       this._start();
   },
new file mode 100644
--- /dev/null
+++ b/mobile/android/modules/WebappManager.jsm
@@ -0,0 +1,215 @@
+/* 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 = ["WebappManager"];
+
+const { classes: Cc, interfaces: Ci, utils: Cu } = Components;
+
+Cu.import("resource://gre/modules/AppsUtils.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/NetUtil.jsm");
+Cu.import("resource://gre/modules/FileUtils.jsm");
+Cu.import("resource://gre/modules/DOMRequestHelper.jsm");
+Cu.import("resource://gre/modules/Webapps.jsm");
+Cu.import("resource://gre/modules/osfile.jsm");
+
+function dump(a) {
+  Services.console.logStringMessage("* * WebappManager.jsm: " + a);
+}
+
+function sendMessageToJava(aMessage) {
+  return Services.androidBridge.handleGeckoMessage(JSON.stringify(aMessage));
+}
+
+this.WebappManager = {
+  __proto__: DOMRequestIpcHelper.prototype,
+
+  downloadApk: function(aMsg) {
+    let manifestUrl = aMsg.app.manifestURL;
+    dump("downloadApk for " + manifestUrl);
+
+    // Get the endpoint URL and convert it to an nsIURI/nsIURL object.
+    const GENERATOR_URL_PREF = "browser.webapps.apkFactoryUrl";
+    const GENERATOR_URL_BASE = Services.prefs.getCharPref(GENERATOR_URL_PREF);
+    let generatorUrl = NetUtil.newURI(GENERATOR_URL_BASE).QueryInterface(Ci.nsIURL);
+
+    // Populate the query part of the URL with the manifest URL parameter.
+    let params = {
+      manifestUrl: manifestUrl,
+    };
+    generatorUrl.query =
+      [p + "=" + encodeURIComponent(params[p]) for (p in params)].join("&");
+    dump("downloading APK from " + generatorUrl.spec);
+
+    let file = Cc["@mozilla.org/download-manager;1"].
+               getService(Ci.nsIDownloadManager).
+               defaultDownloadsDirectory.
+               clone();
+    file.append(manifestUrl.replace(/[^a-zA-Z0-9]/gi, "") + ".apk");
+    file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+    dump("downloading APK to " + file.path);
+
+    let worker = new ChromeWorker("resource://gre/modules/WebappManagerWorker.js");
+    worker.onmessage = function(event) {
+      let { type, message } = event.data;
+
+      worker.terminate();
+
+      if (type == "success") {
+        sendMessageToJava({
+          type: "WebApps:InstallApk",
+          filePath: file.path,
+          data: JSON.stringify(aMsg),
+        });
+      } else { // type == "failure"
+        // TODO: handle error better.
+        dump("error downloading APK: " + message);
+      }
+    }
+
+    // Trigger the download.
+    worker.postMessage({ url: generatorUrl.spec, path: file.path });
+  },
+
+  askInstall: function(aData) {
+    let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
+    file.initWithPath(aData.profilePath);
+
+    // We don't yet support pre-installing an appcache because it isn't clear
+    // how to do it without degrading the user experience (since users expect
+    // apps to be available after the system tells them they've been installed,
+    // which has already happened) and because nsCacheService shuts down
+    // when we trigger the native install dialog and doesn't re-init itself
+    // afterward (TODO: file bug about this behavior).
+    if ("appcache_path" in aData.app.manifest) {
+      dump("deleting appcache_path from manifest: " + aData.app.manifest.appcache_path);
+      delete aData.app.manifest.appcache_path;
+    }
+
+    DOMApplicationRegistry.confirmInstall(aData, file, (function(aManifest) {
+      let localeManifest = new ManifestHelper(aManifest, aData.app.origin);
+
+      // aData.app.origin may now point to the app: url that hosts this app.
+      sendMessageToJava({
+        type: "WebApps:PostInstall",
+        packageName: aData.app.packageName,
+        origin: aData.app.origin,
+      });
+
+      this.writeDefaultPrefs(file, localeManifest);
+    }).bind(this));
+  },
+
+  launch: function({ manifestURL, origin }) {
+    dump("launchWebapp: " + manifestURL);
+
+    sendMessageToJava({
+      type: "WebApps:Open",
+      manifestURL: manifestURL,
+      origin: origin
+    });
+  },
+
+  uninstall: function(aData) {
+    dump("uninstall: " + aData.manifestURL);
+
+    // TODO: uninstall the APK.
+  },
+
+  autoInstall: function(aData) {
+    let mm = {
+      sendAsyncMessage: function (aMessageName, aData) {
+        // TODO hook this back to Java to report errors.
+        dump("sendAsyncMessage " + aMessageName + ": " + JSON.stringify(aData));
+      }
+    };
+
+    let origin = Services.io.newURI(aData.manifestUrl, null, null).prePath;
+
+    let message = aData.request || {
+      app: {
+        origin: origin
+      }
+    };
+
+    if (aData.updateManifest) {
+      if (aData.zipFilePath) {
+        aData.updateManifest.package_path = aData.zipFilePath;
+      }
+      message.app.updateManifest = aData.updateManifest;
+    }
+
+    // The manifest url may be subtly different between the
+    // time the APK was built and the APK being installed.
+    // Thus, we should take the APK as the source of truth.
+    message.app.manifestURL = aData.manifestUrl;
+    message.app.manifest = aData.manifest;
+    message.app.packageName = aData.packageName;
+    message.profilePath = aData.profilePath;
+    message.autoInstall = true;
+    message.mm = mm;
+
+    switch (aData.type) { // can be hosted or packaged.
+      case "hosted":
+        DOMApplicationRegistry.doInstall(message, mm);
+        break;
+
+      case "packaged":
+        message.isPackage = true;
+        DOMApplicationRegistry.doInstallPackage(message, mm);
+        break;
+    }
+  },
+
+  autoUninstall: function(aData) {
+    let mm = {
+      sendAsyncMessage: function (aMessageName, aData) {
+        // TODO hook this back to Java to report errors.
+        dump("autoUninstall sendAsyncMessage " + aMessageName + ": " + JSON.stringify(aData));
+      }
+    };
+    let installedPackages = {};
+    DOMApplicationRegistry.doGetAll(installedPackages, mm);
+
+    for (let app in installedPackages.apps) {
+      if (aData.packages.indexOf(installedPackages.apps[app].packageName) > -1) {
+        let appToRemove = installedPackages.apps[app];
+        dump("should remove: " + appToRemove.name);
+        DOMApplicationRegistry.uninstall(appToRemove.manifestURL, function() {
+          dump(appToRemove.name + " uninstalled");
+        }, function() {
+          dump(appToRemove.name + " did not uninstall");
+        });
+      }
+    }
+  },
+
+  writeDefaultPrefs: function(aProfile, aManifest) {
+      // build any app specific default prefs
+      let prefs = [];
+      if (aManifest.orientation) {
+        prefs.push({name:"app.orientation.default", value: aManifest.orientation.join(",") });
+      }
+
+      // write them into the app profile
+      let defaultPrefsFile = aProfile.clone();
+      defaultPrefsFile.append(this.DEFAULT_PREFS_FILENAME);
+      this._writeData(defaultPrefsFile, prefs);
+  },
+
+  _writeData: function(aFile, aPrefs) {
+    if (aPrefs.length > 0) {
+      let array = new TextEncoder().encode(JSON.stringify(aPrefs));
+      OS.File.writeAtomic(aFile.path, array, { tmpPath: aFile.path + ".tmp" }).then(null, function onError(reason) {
+        dump("Error writing default prefs: " + reason);
+      });
+    }
+  },
+
+  DEFAULT_PREFS_FILENAME: "default-prefs.js",
+
+};
new file mode 100644
--- /dev/null
+++ b/mobile/android/modules/WebappManagerWorker.js
@@ -0,0 +1,41 @@
+/* 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/. */
+
+importScripts("resource://gre/modules/osfile.jsm");
+
+function log(message) {
+  dump("WebManagerWorker " + message + "\n");
+}
+
+onmessage = function(event) {
+  let { url, path } = event.data;
+
+  let file = OS.File.open(path, { truncate: true });
+  let request = new XMLHttpRequest({ mozSystem: true });
+
+  request.open("GET", url, true);
+  request.responseType = "moz-chunked-arraybuffer";
+
+  request.onprogress = function(event) {
+    log("onprogress: received " + request.response.byteLength + " bytes");
+    let bytesWritten = file.write(new Uint8Array(request.response));
+    log("onprogress: wrote " + bytesWritten + " bytes");
+  };
+
+  request.onreadystatechange = function(event) {
+    log("onreadystatechange: " + request.readyState);
+
+    if (request.readyState == 4) {
+      file.close();
+
+      if (request.status == 200 || request.status == 0) {
+        postMessage({ type: "success" });
+      } else {
+        postMessage({ type: "failure", message: request.statusText });
+      }
+    }
+  };
+
+  request.send(null);
+}
--- a/mobile/android/modules/moz.build
+++ b/mobile/android/modules/moz.build
@@ -12,8 +12,14 @@ EXTRA_JS_MODULES += [
     'LightweightThemeConsumer.jsm',
     'Notifications.jsm',
     'OrderedBroadcast.jsm',
     'Prompt.jsm',
     'Sanitizer.jsm',
     'SharedPreferences.jsm',
     'SimpleServiceDiscovery.jsm',
 ]
+
+if CONFIG['MOZ_ANDROID_SYNTHAPKS']:
+    EXTRA_JS_MODULES += [
+        'WebappManager.jsm',
+        'WebappManagerWorker.js',
+    ]