Bug 1353868 - Split out web app manifest handling and fix scope handling r=esawin
MozReview-Commit-ID: 7MpeqitYRW8
--- 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