Bug 1353868 - Split out web app manifest handling and fix scope handling r=esawin draft
authorJames Willcox <snorp@snorp.net>
Fri, 25 Aug 2017 11:45:47 -0500
changeset 654391 9544dd2cfef7a37f5f838265a863c84fd1c82bcd
parent 653703 3b30c61c4e4bba67af395eec08f4305664d87157
child 728558 cd386fb1c8c74b4c7dc96c8f70e2485cc66c3c8f
push id76565
push userbmo:snorp@snorp.net
push dateMon, 28 Aug 2017 18:45:06 +0000
reviewersesawin
bugs1353868
milestone57.0a1
Bug 1353868 - Split out web app manifest handling and fix scope handling r=esawin MozReview-Commit-ID: 7MpeqitYRW8
mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
mobile/android/base/java/org/mozilla/gecko/webapps/WebAppActivity.java
mobile/android/base/java/org/mozilla/gecko/webapps/WebAppManifest.java
mobile/android/base/moz.build
mobile/android/chrome/content/browser.js
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -2062,22 +2062,23 @@ public class BrowserApp extends GeckoApp
                 Telemetry.addToHistogram("FENNEC_ORBOT_INSTALLED",
                     ContextUtils.isPackageInstalled(this, "org.torproject.android") ? 1 : 0);
                 break;
 
             case "Website:AppInstalled":
                 final String name = message.getString("name");
                 final String startUrl = message.getString("start_url");
                 final String manifestPath = message.getString("manifest_path");
+                final String manifestUrl = message.getString("manifest_url");
                 final LoadFaviconResult loadIconResult = FaviconDecoder
                     .decodeDataURI(this, message.getString("icon"));
                 if (loadIconResult != null) {
                     final Bitmap icon = loadIconResult
                         .getBestBitmap(GeckoAppShell.getPreferredIconSize());
-                    GeckoApplication.createAppShortcut(name, startUrl, manifestPath, icon);
+                    GeckoApplication.createAppShortcut(name, startUrl, manifestPath, manifestUrl, icon);
                 } else {
                     Log.e(LOGTAG, "Failed to load icon!");
                 }
 
                 break;
 
             case "Website:AppInstallFailed":
                 final String title = message.getString("title");
--- a/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
+++ b/mobile/android/base/java/org/mozilla/gecko/GeckoApplication.java
@@ -506,21 +506,23 @@ public class GeckoApplication extends Ap
         shortcutIntent.setAction(GeckoApp.ACTION_HOMESCREEN_SHORTCUT);
         shortcutIntent.setData(Uri.parse(aURI));
         shortcutIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME,
                                     AppConstants.MOZ_ANDROID_BROWSER_INTENT_CLASS);
         createHomescreenIcon(shortcutIntent, aTitle, aURI, aIcon);
     }
 
     public static void createAppShortcut(final String aTitle, final String aURI,
-                                         final String manifestPath, final Bitmap aIcon) {
+                                         final String manifestPath, final String manifestUrl,
+                                         final Bitmap aIcon) {
         final Intent shortcutIntent = new Intent();
         shortcutIntent.setAction(GeckoApp.ACTION_WEBAPP);
         shortcutIntent.setData(Uri.parse(aURI));
         shortcutIntent.putExtra("MANIFEST_PATH", manifestPath);
+        shortcutIntent.putExtra("MANIFEST_URL", manifestUrl);
         shortcutIntent.setClassName(AppConstants.ANDROID_PACKAGE_NAME,
                                     LauncherActivity.class.getName());
         Telemetry.sendUIEvent(TelemetryContract.Event.ACTION,
                               TelemetryContract.Method.CONTEXT_MENU,
                               "pwa_add_to_launcher");
         createHomescreenIcon(shortcutIntent, aTitle, aURI, aIcon);
     }
 
--- a/mobile/android/base/java/org/mozilla/gecko/webapps/WebAppActivity.java
+++ b/mobile/android/base/java/org/mozilla/gecko/webapps/WebAppActivity.java
@@ -15,59 +15,57 @@ import android.content.Intent;
 import android.content.pm.ActivityInfo;
 import android.graphics.Bitmap;
 import android.net.Uri;
 import android.os.Build;
 import android.os.Bundle;
 import android.support.v7.app.ActionBar;
 import android.support.v7.app.AppCompatActivity;
 import android.support.v7.widget.Toolbar;
-import android.text.TextUtils;
 import android.util.Log;
 import android.view.View;
 import android.view.Window;
 import android.view.WindowManager;
 import android.widget.TextView;
 
-import org.json.JSONObject;
-import org.json.JSONException;
-
 import org.mozilla.gecko.ActivityHandlerHelper;
 import org.mozilla.gecko.AppConstants;
 import org.mozilla.gecko.customtabs.CustomTabsActivity;
 import org.mozilla.gecko.DoorHangerPopup;
-import org.mozilla.gecko.GeckoAppShell;
 import org.mozilla.gecko.GeckoScreenOrientation;
 import org.mozilla.gecko.GeckoView;
 import org.mozilla.gecko.GeckoViewSettings;
-import org.mozilla.gecko.icons.decoders.FaviconDecoder;
-import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
 import org.mozilla.gecko.permissions.Permissions;
 import org.mozilla.gecko.prompts.PromptService;
 import org.mozilla.gecko.R;
 import org.mozilla.gecko.util.ActivityUtils;
 import org.mozilla.gecko.util.ColorUtil;
-import org.mozilla.gecko.util.FileUtils;
 
 public class WebAppActivity extends AppCompatActivity
                             implements GeckoView.NavigationListener {
     private static final String LOGTAG = "WebAppActivity";
 
     public static final String MANIFEST_PATH = "MANIFEST_PATH";
+    public static final String MANIFEST_URL = "MANIFEST_URL";
     private static final String SAVED_INTENT = "savedIntent";
 
     private GeckoView mGeckoView;
     private PromptService mPromptService;
     private DoorHangerPopup mDoorHangerPopup;
 
     private boolean mIsFullScreenMode;
     private boolean mIsFullScreenContent;
     private boolean mCanGoBack;
+
+    private Uri mManifestUrl;
+    private Uri mStartUrl;
     private Uri mScope;
 
+    private WebAppManifest mManifest;
+
     @Override
     public void onCreate(Bundle savedInstanceState) {
         if ((getIntent().getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0 &&
             savedInstanceState != null) {
             // Even though we're a single task activity, Android's task switcher has the
             // annoying habit of never updating its stored intent after our initial creation,
             // even if we've been subsequently started with a new intent.
 
@@ -92,24 +90,24 @@ public class WebAppActivity extends AppC
         });
 
         mPromptService = new PromptService(this, mGeckoView.getEventDispatcher());
         mDoorHangerPopup = new DoorHangerPopup(this, mGeckoView.getEventDispatcher());
 
         final GeckoViewSettings settings = mGeckoView.getSettings();
         settings.setBoolean(GeckoViewSettings.USE_MULTIPROCESS, false);
 
-        final Uri u = getIntent().getData();
-        if (u != null) {
-            mGeckoView.loadUri(u.toString());
-        }
+        mManifest = WebAppManifest.fromFile(getIntent().getStringExtra(MANIFEST_URL),
+                                            getIntent().getStringExtra(MANIFEST_PATH));
+
+        updateFromManifest();
+
+        mGeckoView.loadUri(mManifest.getStartUri().toString());
 
         setContentView(mGeckoView);
-
-        loadManifest(getIntent().getStringExtra(MANIFEST_PATH));
     }
 
     @Override
     public void onDestroy() {
         mDoorHangerPopup.destroy();
         mPromptService.destroy();
 
         super.onDestroy();
@@ -148,77 +146,64 @@ public class WebAppActivity extends AppC
             mGeckoView.exitFullScreen();
         } else if (mCanGoBack) {
             mGeckoView.goBack();
         } else {
             super.onBackPressed();
         }
     }
 
-    private void loadManifest(String manifestPath) {
-        if (TextUtils.isEmpty(manifestPath)) {
-            Log.e(LOGTAG, "Missing manifest");
-            return;
+    private void updateFromManifest() {
+        if (AppConstants.Versions.feature21Plus) {
+            updateTaskAndStatusBar();
         }
 
-        try {
-            final File manifestFile = new File(manifestPath);
-            final JSONObject manifest = FileUtils.readJSONObjectFromFile(manifestFile);
-            final JSONObject manifestField = manifest.getJSONObject("manifest");
-
-            if (AppConstants.Versions.feature21Plus) {
-                loadManifestV21(manifest, manifestField);
-            }
-
-            updateScreenOrientation(manifestField);
-            updateDisplayMode(manifestField);
-        } catch (IOException | JSONException e) {
-            Log.e(LOGTAG, "Failed to read manifest", e);
-        }
+        updateScreenOrientation();
+        updateDisplayMode();
     }
 
     // The customisations defined in the manifest only work on Android API 21+
     @TargetApi(Build.VERSION_CODES.LOLLIPOP)
-    private void loadManifestV21(JSONObject manifest, JSONObject manifestField) {
-        final Integer color = readColorFromManifest(manifestField);
-        final String name = readNameFromManifest(manifestField);
-        final Bitmap icon = readIconFromManifest(manifest);
-        mScope = readScopeFromManifest(manifest);
-        final ActivityManager.TaskDescription taskDescription = (color == null)
+    private void updateTaskAndStatusBar() {
+        final Integer themeColor = mManifest.getThemeColor();
+        final String name = mManifest.getName();
+        final Bitmap icon = mManifest.getIcon();
+
+        final ActivityManager.TaskDescription taskDescription = (themeColor == null)
             ? new ActivityManager.TaskDescription(name, icon)
-            : new ActivityManager.TaskDescription(name, icon, color);
+            : new ActivityManager.TaskDescription(name, icon, themeColor);
 
-        updateStatusBarColorV21(color);
+        updateStatusBarColorV21(themeColor);
         setTaskDescription(taskDescription);
     }
 
     @TargetApi(Build.VERSION_CODES.LOLLIPOP)
     private void updateStatusBarColorV21(final Integer themeColor) {
         if (themeColor != null) {
             final Window window = getWindow();
             window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
             window.setStatusBarColor(ColorUtil.darken(themeColor, 0.25));
         }
     }
 
-    private void updateScreenOrientation(JSONObject manifest) {
-        String orientString = manifest.optString("orientation", null);
+    private void updateScreenOrientation() {
+        String orientString = mManifest.getOrientation();
         if (orientString == null) {
             return;
         }
 
         GeckoScreenOrientation.ScreenOrientation orientation =
             GeckoScreenOrientation.screenOrientationFromString(orientString);
         int activityOrientation = GeckoScreenOrientation.screenOrientationToAndroidOrientation(orientation);
 
         setRequestedOrientation(activityOrientation);
     }
 
-    private void updateDisplayMode(JSONObject manifest) {
-        String displayMode = manifest.optString("display");
+    private void updateDisplayMode() {
+        String displayMode = mManifest.getDisplayMode();
 
         updateFullScreenMode(displayMode.equals("fullscreen"));
 
         GeckoViewSettings.DisplayMode mode;
         switch (displayMode) {
             case "standalone":
                 mode = GeckoViewSettings.DisplayMode.STANDALONE;
                 break;
@@ -232,120 +217,44 @@ public class WebAppActivity extends AppC
             default:
                 mode = GeckoViewSettings.DisplayMode.BROWSER;
                 break;
         }
 
         mGeckoView.getSettings().setInt(GeckoViewSettings.DISPLAY_MODE, mode.value());
     }
 
-    private Integer readColorFromManifest(JSONObject manifest) {
-        final String colorStr = manifest.optString("theme_color", null);
-        if (colorStr != null) {
-            return ColorUtil.parseStringColor(colorStr);
-        }
-        return null;
-    }
-
-    private String readNameFromManifest(JSONObject manifest) {
-        String name = manifest.optString("name", null);
-        if (name == null) {
-            name = manifest.optString("short_name", null);
-        }
-        if (name == null) {
-            name = manifest.optString("start_url", null);
-        }
-        return name;
-    }
-
-    private Bitmap readIconFromManifest(JSONObject manifest) {
-        final String iconStr = manifest.optString("cached_icon", null);
-        if (iconStr == null) {
-            return null;
-        }
-        final LoadFaviconResult loadIconResult = FaviconDecoder
-            .decodeDataURI(this, iconStr);
-        if (loadIconResult == null) {
-            return null;
-        }
-        return loadIconResult.getBestBitmap(GeckoAppShell.getPreferredIconSize());
-    }
-
-    private Uri readScopeFromManifest(JSONObject manifest) {
-        final String scopeStr = manifest.optString("scope", null);
-        if (scopeStr == null) {
-            return null;
-        }
-
-        Uri res = Uri.parse(scopeStr);
-        if (res.isRelative()) {
-            // TODO: Handle this more correctly.
-            return null;
-        }
-
-        return res;
-    }
-
-    private boolean isInScope(String url) {
-        if (mScope == null) {
-            return true;
-        }
-
-        final Uri uri = Uri.parse(url);
-
-        if (!uri.getScheme().equals(mScope.getScheme())) {
-            return false;
-        }
-
-        if (!uri.getHost().equals(mScope.getHost())) {
-            return false;
-        }
-
-        final List<String> scopeSegments = mScope.getPathSegments();
-        final List<String> urlSegments = uri.getPathSegments();
-
-        if (scopeSegments.size() > urlSegments.size()) {
-            return false;
-        }
-
-        for (int i = 0; i < scopeSegments.size(); i++) {
-            if (!scopeSegments.get(i).equals(urlSegments.get(i))) {
-                return false;
-            }
-        }
-
-        return true;
-    }
-
     /* GeckoView.NavigationListener */
     @Override
     public void onLocationChange(GeckoView view, String url) {
     }
 
     @Override
     public void onCanGoBack(GeckoView view, boolean canGoBack) {
         mCanGoBack = canGoBack;
     }
 
     @Override
     public void onCanGoForward(GeckoView view, boolean canGoForward) {
     }
 
     @Override
-    public boolean onLoadUri(final GeckoView view, final String uri,
+    public boolean onLoadUri(final GeckoView view, final String url,
                              final TargetWindow where) {
-        if (isInScope(uri)) {
-            view.loadUri(uri);
-        } else {
-            final Intent intent = new Intent(getIntent());
-            intent.setClassName(getApplicationContext(),
-                                CustomTabsActivity.class.getName());
-            intent.setData(Uri.parse(uri));
-            startActivity(intent);
+        if (mManifest.isInScope(url) && where != TargetWindow.NEW) {
+            // This is in scope and wants to load in the same frame, so
+            // let Gecko handle it.
+            return false;
         }
+
+        final Intent intent = new Intent(getIntent());
+        intent.setClassName(getApplicationContext(),
+                            CustomTabsActivity.class.getName());
+        intent.setData(Uri.parse(url));
+        startActivity(intent);
         return true;
     }
 
     private void updateFullScreen() {
         boolean fullScreen = mIsFullScreenContent || mIsFullScreenMode;
         if (ActivityUtils.isFullScreen(this) == fullScreen) {
             return;
         }
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/webapps/WebAppManifest.java
@@ -0,0 +1,232 @@
+/* -*- 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.webapps;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedList;
+import java.util.List;
+
+import android.annotation.TargetApi;
+import android.graphics.Bitmap;
+import android.net.Uri;
+import android.text.TextUtils;
+import android.util.Log;
+
+import org.json.JSONObject;
+import org.json.JSONException;
+
+import org.mozilla.gecko.GeckoAppShell;
+import org.mozilla.gecko.icons.decoders.FaviconDecoder;
+import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
+import org.mozilla.geckos.util.ColorUtil;
+import org.mozilla.gecko.util.FileUtils;
+
+public class WebAppManifest {
+    private static final String LOGTAG = "WebAppManifest";
+
+    private Uri mManifestUri;
+
+    private Integer mThemeColor;
+    private String mName;
+    private Bitmap mIcon;
+    private Uri mScope;
+    private Uri mStartUri;
+    private String mDisplayMode;
+    private String mOrientation;
+
+    public static WebAppManifest fromFile(String url, String path) {
+        if (url == null || TextUtils.isEmpty(url)) {
+            throw new IllegalArgumentException("Must pass a non-empty manifest URL");
+        }
+
+        if (path == null || TextUtils.isEmpty(path)) {
+            throw new IllegalArgumentException("Must pass a non-empty manifest path");
+        }
+
+        final Uri manifestUri = Uri.parse(url);
+        if (manifestUri == null) {
+            throw new IllegalArgumentException("Must pass a valid manifest URL");
+        }
+
+        try {
+            final File manifestFile = new File(path);
+
+            // Gecko adds some add some additional data, such as cached_icon, in
+            // the toplevel object. The actual webapp manifest is in the "manifest" field.
+            final JSONObject manifest = FileUtils.readJSONObjectFromFile(manifestFile);
+            final JSONObject manifestField = manifest.getJSONObject("manifest");
+
+            return new WebAppManifest(manifestUri, manifest, manifestField);
+        } catch (Exception e) {
+            Log.e(LOGTAG, "Failed to read webapp manifest", e);
+            return null;
+        }
+    }
+
+    private WebAppManifest(Uri uri, JSONObject manifest, JSONObject manifestField) {
+        mManifestUri = uri;
+        readManifest(manifest, manifestField);
+    }
+
+    public Integer getThemeColor() {
+        return mThemeColor;
+    }
+
+    public String getName() {
+        return mName;
+    }
+
+    public Bitmap getIcon() {
+        return mIcon;
+    }
+
+    public Uri getScope() {
+        return mScope;
+    }
+
+    public Uri getStartUri() {
+        return mStartUri;
+    }
+
+    public String getDisplayMode() {
+        return mDisplayMode;
+    }
+
+    public String getOrientation() {
+        return mOrientation;
+    }
+
+    private void readManifest(JSONObject manifest, JSONObject manifestField) {
+        mThemeColor = readThemeColor(manifestField);
+        mName = readName(manifestField);
+        mIcon = readIcon(manifest);
+        mScope = readScope(manifestField);
+        mStartUri = readStartUrl(manifestField);
+
+        mDisplayMode = manifestField.optString("display");
+        mOrientation = manifestField.optString("orientation");
+    }
+
+    private Integer readThemeColor(JSONObject manifest) {
+        final String colorStr = manifest.optString("theme_color", null);
+        if (colorStr != null) {
+            return ColorUtil.parseStringColor(colorStr);
+        }
+        return null;
+    }
+
+    private Uri buildRelativeUrl(Uri base, Uri relative) {
+        Uri.Builder builder = new Uri.Builder()
+            .scheme(base.getScheme())
+            .authority(base.getAuthority());
+
+        try {
+            // This is pretty gross, but seems to be the easiest way to get
+            // a normalized path without java.nio.Path[s], which is
+            // only in SDK 26.
+            File file = new File(base.getPath() + "/" + relative.getPath());
+            builder.path(file.getCanonicalPath());
+        } catch (java.io.IOException e) {
+            return null;
+        }
+
+        return builder.query(relative.getQuery())
+            .fragment(relative.getFragment())
+            .build();
+    }
+
+    private Uri readStartUrl(JSONObject manifest) {
+        Uri startUrl = Uri.parse(manifest.optString("start_url", "/"));
+        if (startUrl.isRelative()) {
+            startUrl = buildRelativeUrl(stripLastPathSegment(mManifestUri), startUrl);
+        }
+
+        return startUrl;
+    }
+
+    private String readName(JSONObject manifest) {
+        String name = manifest.optString("name", null);
+        if (name == null) {
+            name = manifest.optString("short_name", null);
+        }
+        if (name == null) {
+            name = manifest.optString("start_url", null);
+        }
+        return name;
+    }
+
+    private Bitmap readIcon(JSONObject manifest) {
+        final String iconStr = manifest.optString("cached_icon", null);
+        if (iconStr == null) {
+            return null;
+        }
+        final LoadFaviconResult loadIconResult = FaviconDecoder
+            .decodeDataURI(GeckoAppShell.getApplicationContext(), iconStr);
+        if (loadIconResult == null) {
+            return null;
+        }
+        return loadIconResult.getBestBitmap(GeckoAppShell.getPreferredIconSize());
+    }
+
+    private static Uri stripLastPathSegment(Uri uri) {
+        Uri.Builder builder = new Uri.Builder()
+            .scheme(uri.getScheme())
+            .authority(uri.getAuthority());
+
+        final List<String> segments = uri.getPathSegments();
+        for (int i = 0; i < (segments.size() - 1); i++) {
+            builder.appendPath(segments.get(i));
+        }
+
+        return builder.build();
+    }
+
+    private Uri readScope(JSONObject manifest) {
+        final String scopeStr = manifest.optString("scope", null);
+        if (scopeStr == null) {
+            return null;
+        }
+
+        Uri scope = Uri.parse(scopeStr);
+        if (scope == null) {
+            return null;
+        }
+
+        if (scope.isRelative()) {
+            scope = buildRelativeUrl(stripLastPathSegment(mManifestUri), scope);
+        }
+
+        return scope;
+    }
+
+    public boolean isInScope(String url) {
+        if (mScope == null) {
+            return true;
+        }
+
+
+        final Uri uri = Uri.parse(url);
+        if (uri == null) {
+            return false;
+        }
+
+        if (!uri.getScheme().equals(mScope.getScheme())) {
+            return false;
+        }
+
+        if (!uri.getHost().equals(mScope.getHost())) {
+            return false;
+        }
+
+        if (!uri.getPath().startsWith(mScope.getPath())) {
+            return false;
+        }
+
+        return true;
+    }
+}
--- a/mobile/android/base/moz.build
+++ b/mobile/android/base/moz.build
@@ -951,16 +951,17 @@ gbjar.sources += ['java/org/mozilla/geck
     'util/JavaUtil.java',
     'util/ResourceDrawableUtils.java',
     'util/TouchTargetUtil.java',
     'util/URIUtils.java',
     'util/ViewUtil.java',
     'util/WindowUtil.java',
     'webapps/WebAppActivity.java',
     'webapps/WebAppIndexer.java',
+    'webapps/WebAppManifest.java',
     'webapps/WebApps.java',
     'widget/ActionModePresenter.java',
     'widget/ActivityChooserModel.java',
     'widget/AllCapsTextView.java',
     'widget/AnchoredPopup.java',
     'widget/AnimatedHeightLayout.java',
     'widget/BasicColorPicker.java',
     'widget/CheckableLinearLayout.java',
--- a/mobile/android/chrome/content/browser.js
+++ b/mobile/android/chrome/content/browser.js
@@ -2201,17 +2201,18 @@ async function installManifest(browser, 
     const manifest = await Manifests.getManifest(browser, data.manifestUrl);
     await manifest.install();
     const icon = await manifest.icon(data.iconSize);
     GlobalEventDispatcher.sendRequest({
       type: "Website:AppInstalled",
       icon,
       name: manifest.name,
       start_url: manifest.start_url,
-      manifest_path: manifest.path
+      manifest_path: manifest.path,
+      manifest_url: manifest.url
     });
   } catch (err) {
     Cu.reportError("Failed to install: " + err.message);
     // If we fail to install via the manifest, we will fall back to a standard bookmark
     GlobalEventDispatcher.sendRequest({
       type: "Website:AppInstallFailed",
       url: data.originalUrl,
       title: data.originalTitle