summaryrefslogtreecommitdiffstats
path: root/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java
diff options
context:
space:
mode:
Diffstat (limited to 'mobile/android/base/java/org/mozilla/gecko/IntentHelper.java')
-rw-r--r--mobile/android/base/java/org/mozilla/gecko/IntentHelper.java593
1 files changed, 593 insertions, 0 deletions
diff --git a/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java b/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java
new file mode 100644
index 000000000..efe9576d7
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/IntentHelper.java
@@ -0,0 +1,593 @@
+/* -*- Mode: Java; c-basic-offset: 4; tab-width: 4; indent-tabs-mode: nil; -*-
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+package org.mozilla.gecko;
+
+import org.mozilla.gecko.overlays.ui.ShareDialog;
+import org.mozilla.gecko.util.ActivityResultHandler;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoEventListener;
+import org.mozilla.gecko.util.JSONUtils;
+import org.mozilla.gecko.util.NativeEventListener;
+import org.mozilla.gecko.util.NativeJSObject;
+import org.mozilla.gecko.widget.ExternalIntentDuringPrivateBrowsingPromptFragment;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import android.annotation.TargetApi;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.provider.Browser;
+import android.support.annotation.Nullable;
+import android.support.v4.app.FragmentActivity;
+import android.text.TextUtils;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLEncoder;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Locale;
+
+public final class IntentHelper implements GeckoEventListener,
+ NativeEventListener {
+
+ private static final String LOGTAG = "GeckoIntentHelper";
+ private static final String[] EVENTS = {
+ "Intent:GetHandlers",
+ "Intent:Open",
+ "Intent:OpenForResult",
+ };
+
+ private static final String[] NATIVE_EVENTS = {
+ "Intent:OpenNoHandler",
+ };
+
+ // via http://developer.android.com/distribute/tools/promote/linking.html
+ private static String MARKET_INTENT_URI_PACKAGE_PREFIX = "market://details?id=";
+ private static String EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url";
+
+ /** A partial URI to an error page - the encoded error URI should be appended before loading. */
+ private static String UNKNOWN_PROTOCOL_URI_PREFIX = "about:neterror?e=unknownProtocolFound&u=";
+
+ private static IntentHelper instance;
+
+ private final FragmentActivity activity;
+
+ private IntentHelper(final FragmentActivity activity) {
+ this.activity = activity;
+ EventDispatcher.getInstance().registerGeckoThreadListener((GeckoEventListener) this, EVENTS);
+ EventDispatcher.getInstance().registerGeckoThreadListener((NativeEventListener) this, NATIVE_EVENTS);
+ }
+
+ public static IntentHelper init(final FragmentActivity activity) {
+ if (instance == null) {
+ instance = new IntentHelper(activity);
+ } else {
+ Log.w(LOGTAG, "IntentHelper.init() called twice, ignoring.");
+ }
+
+ return instance;
+ }
+
+ public static void destroy() {
+ if (instance != null) {
+ EventDispatcher.getInstance().unregisterGeckoThreadListener((GeckoEventListener) instance, EVENTS);
+ EventDispatcher.getInstance().unregisterGeckoThreadListener((NativeEventListener) instance, NATIVE_EVENTS);
+ instance = null;
+ }
+ }
+
+ /**
+ * Given the inputs to <code>getOpenURIIntent</code>, plus an optional
+ * package name and class name, create and fire an intent to open the
+ * provided URI. If a class name is specified but a package name is not,
+ * we will default to using the current fennec package.
+ *
+ * @param targetURI the string spec of the URI to open.
+ * @param mimeType an optional MIME type string.
+ * @param packageName an optional app package name.
+ * @param className an optional intent class name.
+ * @param action an Android action specifier, such as
+ * <code>Intent.ACTION_SEND</code>.
+ * @param title the title to use in <code>ACTION_SEND</code> intents.
+ * @param showPromptInPrivateBrowsing whether or not the user should be prompted when opening
+ * this uri from private browsing. This should be true
+ * when the user doesn't explicitly choose to open an an
+ * external app (e.g. just clicked a link).
+ * @return true if the activity started successfully or the user was prompted to open the
+ * application; false otherwise.
+ */
+ public static boolean openUriExternal(String targetURI,
+ String mimeType,
+ String packageName,
+ String className,
+ String action,
+ String title,
+ final boolean showPromptInPrivateBrowsing) {
+ final GeckoAppShell.GeckoInterface gi = GeckoAppShell.getGeckoInterface();
+ final Context activityContext = gi != null ? gi.getActivity() : null;
+ final Context context = activityContext != null ? activityContext : GeckoAppShell.getApplicationContext();
+ final Intent intent = getOpenURIIntent(context, targetURI,
+ mimeType, action, title);
+
+ if (intent == null) {
+ return false;
+ }
+
+ if (!TextUtils.isEmpty(className)) {
+ if (!TextUtils.isEmpty(packageName)) {
+ intent.setClassName(packageName, className);
+ } else {
+ // Default to using the fennec app context.
+ intent.setClassName(context, className);
+ }
+ }
+
+ if (!showPromptInPrivateBrowsing || activityContext == null) {
+ if (activityContext == null) {
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ }
+ return ActivityHandlerHelper.startIntentAndCatch(LOGTAG, context, intent);
+ } else {
+ // Ideally we retrieve the Activity from the calling args, rather than
+ // statically, but since this method is called from Gecko and I'm
+ // unfamiliar with that code, this is a simpler solution.
+ final FragmentActivity fragmentActivity = (FragmentActivity) activityContext;
+ return ExternalIntentDuringPrivateBrowsingPromptFragment.showDialogOrAndroidChooser(
+ context, fragmentActivity.getSupportFragmentManager(), intent);
+ }
+ }
+
+ public static boolean hasHandlersForIntent(Intent intent) {
+ try {
+ return !GeckoAppShell.queryIntentActivities(intent).isEmpty();
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Exception in hasHandlersForIntent");
+ return false;
+ }
+ }
+
+ public static String[] getHandlersForIntent(Intent intent) {
+ final PackageManager pm = GeckoAppShell.getApplicationContext().getPackageManager();
+ try {
+ final List<ResolveInfo> list = GeckoAppShell.queryIntentActivities(intent);
+
+ int numAttr = 4;
+ final String[] ret = new String[list.size() * numAttr];
+ for (int i = 0; i < list.size(); i++) {
+ ResolveInfo resolveInfo = list.get(i);
+ ret[i * numAttr] = resolveInfo.loadLabel(pm).toString();
+ if (resolveInfo.isDefault)
+ ret[i * numAttr + 1] = "default";
+ else
+ ret[i * numAttr + 1] = "";
+ ret[i * numAttr + 2] = resolveInfo.activityInfo.applicationInfo.packageName;
+ ret[i * numAttr + 3] = resolveInfo.activityInfo.name;
+ }
+ return ret;
+ } catch (Exception ex) {
+ Log.e(LOGTAG, "Exception in getHandlersForIntent");
+ return new String[0];
+ }
+ }
+
+ public static Intent getIntentForActionString(String aAction) {
+ // Default to the view action if no other action as been specified.
+ if (TextUtils.isEmpty(aAction)) {
+ return new Intent(Intent.ACTION_VIEW);
+ }
+ return new Intent(aAction);
+ }
+
+ /**
+ * Given a URI, a MIME type, and a title,
+ * produce a share intent which can be used to query all activities
+ * than can open the specified URI.
+ *
+ * @param context a <code>Context</code> instance.
+ * @param targetURI the string spec of the URI to open.
+ * @param mimeType an optional MIME type string.
+ * @param title the title to use in <code>ACTION_SEND</code> intents.
+ * @return an <code>Intent</code>, or <code>null</code> if none could be
+ * produced.
+ */
+ public static Intent getShareIntent(final Context context,
+ final String targetURI,
+ final String mimeType,
+ final String title) {
+ Intent shareIntent = getIntentForActionString(Intent.ACTION_SEND);
+ shareIntent.putExtra(Intent.EXTRA_TEXT, targetURI);
+ shareIntent.putExtra(Intent.EXTRA_SUBJECT, title);
+ shareIntent.putExtra(ShareDialog.INTENT_EXTRA_DEVICES_ONLY, true);
+
+ // Note that EXTRA_TITLE is intended to be used for share dialog
+ // titles. Common usage (e.g., Pocket) suggests that it's sometimes
+ // interpreted as an alternate to EXTRA_SUBJECT, so we include it.
+ shareIntent.putExtra(Intent.EXTRA_TITLE, title);
+
+ if (mimeType != null && mimeType.length() > 0) {
+ shareIntent.setType(mimeType);
+ }
+
+ return shareIntent;
+ }
+
+ /**
+ * Given a URI, a MIME type, an Android intent "action", and a title,
+ * produce an intent which can be used to start an activity to open
+ * the specified URI.
+ *
+ * @param context a <code>Context</code> instance.
+ * @param targetURI the string spec of the URI to open.
+ * @param mimeType an optional MIME type string.
+ * @param action an Android action specifier, such as
+ * <code>Intent.ACTION_SEND</code>.
+ * @param title the title to use in <code>ACTION_SEND</code> intents.
+ * @return an <code>Intent</code>, or <code>null</code> if none could be
+ * produced.
+ */
+ static Intent getOpenURIIntent(final Context context,
+ final String targetURI,
+ final String mimeType,
+ final String action,
+ final String title) {
+
+ // The resultant chooser can return non-exported activities in 4.1 and earlier.
+ // https://code.google.com/p/android/issues/detail?id=29535
+ final Intent intent = getOpenURIIntentInner(context, targetURI, mimeType, action, title);
+
+ if (intent != null) {
+ // Some applications use this field to return to the same browser after processing the
+ // Intent. While there is some danger (e.g. denial of service), other major browsers already
+ // use it and so it's the norm.
+ intent.putExtra(Browser.EXTRA_APPLICATION_ID, AppConstants.ANDROID_PACKAGE_NAME);
+ }
+
+ return intent;
+ }
+
+ private static Intent getOpenURIIntentInner(final Context context, final String targetURI,
+ final String mimeType, final String action, final String title) {
+
+ if (action.equalsIgnoreCase(Intent.ACTION_SEND)) {
+ Intent shareIntent = getShareIntent(context, targetURI, mimeType, title);
+ return Intent.createChooser(shareIntent,
+ context.getResources().getString(R.string.share_title));
+ }
+
+ Uri uri = normalizeUriScheme(targetURI.indexOf(':') >= 0 ? Uri.parse(targetURI) : new Uri.Builder().scheme(targetURI).build());
+ if (!TextUtils.isEmpty(mimeType)) {
+ Intent intent = getIntentForActionString(action);
+ intent.setDataAndType(uri, mimeType);
+ return intent;
+ }
+
+ if (!GeckoAppShell.isUriSafeForScheme(uri)) {
+ return null;
+ }
+
+ final String scheme = uri.getScheme();
+ if ("intent".equals(scheme) || "android-app".equals(scheme)) {
+ final Intent intent;
+ try {
+ intent = Intent.parseUri(targetURI, 0);
+ } catch (final URISyntaxException e) {
+ Log.e(LOGTAG, "Unable to parse URI - " + e);
+ return null;
+ }
+
+ // Only open applications which can accept arbitrary data from a browser.
+ intent.addCategory(Intent.CATEGORY_BROWSABLE);
+
+ // Prevent site from explicitly opening our internal activities, which can leak data.
+ intent.setComponent(null);
+ nullIntentSelector(intent);
+
+ return intent;
+ }
+
+ // Compute our most likely intent, then check to see if there are any
+ // custom handlers that would apply.
+ // Start with the original URI. If we end up modifying it, we'll
+ // overwrite it.
+ final String extension = MimeTypeMap.getFileExtensionFromUrl(targetURI);
+ final Intent intent = getIntentForActionString(action);
+ intent.setData(uri);
+
+ if ("file".equals(scheme)) {
+ // Only set explicit mimeTypes on file://.
+ final String mimeType2 = GeckoAppShell.getMimeTypeFromExtension(extension);
+ intent.setType(mimeType2);
+ return intent;
+ }
+
+ // Have a special handling for SMS based schemes, as the query parameters
+ // are not extracted from the URI automatically.
+ if (!"sms".equals(scheme) && !"smsto".equals(scheme) && !"mms".equals(scheme) && !"mmsto".equals(scheme)) {
+ return intent;
+ }
+
+ final String query = uri.getEncodedQuery();
+ if (TextUtils.isEmpty(query)) {
+ return intent;
+ }
+
+ // It is common to see sms*/mms* uris on the web without '//', it is W3C standard not to have the slashes,
+ // but android's Uri builder & Uri require the slashes and will interpret those without as malformed.
+ String currentUri = uri.toString();
+ String correctlyFormattedDataURIScheme = scheme + "://";
+ if (!currentUri.contains(correctlyFormattedDataURIScheme)) {
+ uri = Uri.parse(currentUri.replaceFirst(scheme + ":", correctlyFormattedDataURIScheme));
+ }
+
+ final String[] fields = query.split("&");
+ boolean shouldUpdateIntent = false;
+ String resultQuery = "";
+ for (String field : fields) {
+ if (field.startsWith("body=")) {
+ final String body = Uri.decode(field.substring(5));
+ intent.putExtra("sms_body", body);
+ shouldUpdateIntent = true;
+ } else if (field.startsWith("subject=")) {
+ final String subject = Uri.decode(field.substring(8));
+ intent.putExtra("subject", subject);
+ shouldUpdateIntent = true;
+ } else if (field.startsWith("cc=")) {
+ final String ccNumber = Uri.decode(field.substring(3));
+ String phoneNumber = uri.getAuthority();
+ if (phoneNumber != null) {
+ uri = uri.buildUpon().encodedAuthority(phoneNumber + ";" + ccNumber).build();
+ }
+ shouldUpdateIntent = true;
+ } else {
+ resultQuery = resultQuery.concat(resultQuery.length() > 0 ? "&" + field : field);
+ }
+ }
+
+ if (!shouldUpdateIntent) {
+ // No need to rewrite the URI, then.
+ return intent;
+ }
+
+ // Form a new URI without the extracted fields in the query part, and
+ // push that into the new Intent.
+ final String newQuery = resultQuery.length() > 0 ? "?" + resultQuery : "";
+ final Uri pruned = uri.buildUpon().encodedQuery(newQuery).build();
+ intent.setData(pruned);
+
+ return intent;
+ }
+
+ // We create a separate method to better encapsulate the @TargetApi use.
+ @TargetApi(15)
+ private static void nullIntentSelector(final Intent intent) {
+ intent.setSelector(null);
+ }
+
+ /**
+ * Return a <code>Uri</code> instance which is equivalent to <code>u</code>,
+ * but with a guaranteed-lowercase scheme as if the API level 16 method
+ * <code>u.normalizeScheme</code> had been called.
+ *
+ * @param u the <code>Uri</code> to normalize.
+ * @return a <code>Uri</code>, which might be <code>u</code>.
+ */
+ private static Uri normalizeUriScheme(final Uri u) {
+ final String scheme = u.getScheme();
+ final String lower = scheme.toLowerCase(Locale.US);
+ if (lower.equals(scheme)) {
+ return u;
+ }
+
+ // Otherwise, return a new URI with a normalized scheme.
+ return u.buildUpon().scheme(lower).build();
+ }
+
+ @Override
+ public void handleMessage(final String event, final NativeJSObject message, final EventCallback callback) {
+ if (event.equals("Intent:OpenNoHandler")) {
+ openNoHandler(message, callback);
+ }
+ }
+
+ @Override
+ public void handleMessage(String event, JSONObject message) {
+ try {
+ if (event.equals("Intent:GetHandlers")) {
+ getHandlers(message);
+ } else if (event.equals("Intent:Open")) {
+ open(message);
+ } else if (event.equals("Intent:OpenForResult")) {
+ openForResult(message);
+ }
+ } catch (JSONException e) {
+ Log.e(LOGTAG, "Exception handling message \"" + event + "\":", e);
+ }
+ }
+
+ private void getHandlers(JSONObject message) throws JSONException {
+ final Intent intent = getOpenURIIntent(activity,
+ message.optString("url"),
+ message.optString("mime"),
+ message.optString("action"),
+ message.optString("title"));
+ final List<String> appList = Arrays.asList(getHandlersForIntent(intent));
+
+ final JSONObject response = new JSONObject();
+ response.put("apps", new JSONArray(appList));
+ EventDispatcher.sendResponse(message, response);
+ }
+
+ private void open(JSONObject message) throws JSONException {
+ openUriExternal(message.optString("url"),
+ message.optString("mime"),
+ message.optString("packageName"),
+ message.optString("className"),
+ message.optString("action"),
+ message.optString("title"), false);
+ }
+
+ private void openForResult(final JSONObject message) throws JSONException {
+ Intent intent = getOpenURIIntent(activity,
+ message.optString("url"),
+ message.optString("mime"),
+ message.optString("action"),
+ message.optString("title"));
+ intent.setClassName(message.optString("packageName"), message.optString("className"));
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ final ResultHandler handler = new ResultHandler(message);
+ try {
+ ActivityHandlerHelper.startIntentForActivity(activity, intent, handler);
+ } catch (SecurityException e) {
+ Log.w(LOGTAG, "Forbidden to launch activity.", e);
+ }
+ }
+
+ /**
+ * Opens a URI without any valid handlers on device. In the best case, a package is specified
+ * and we can bring the user directly to the application page in an app market. If a package is
+ * not specified and there is a fallback url in the intent extras, we open that url. If neither
+ * is present, we alert the user that we were unable to open the link.
+ *
+ * @param msg A message with the uri with no handlers as the value for the "uri" key
+ * @param callback A callback that will be called with success & no params if Java loads a page, or with error and
+ * the uri to load if Java does not load a page
+ */
+ private void openNoHandler(final NativeJSObject msg, final EventCallback callback) {
+ final String uri = msg.getString("uri");
+
+ if (TextUtils.isEmpty(uri)) {
+ Log.w(LOGTAG, "Received empty URL - loading about:neterror");
+ callback.sendError(getUnknownProtocolErrorPageUri(""));
+ return;
+ }
+
+ final Intent intent;
+ try {
+ // TODO (bug 1173626): This will not handle android-app uris on non 5.1 devices.
+ intent = Intent.parseUri(uri, 0);
+ } catch (final URISyntaxException e) {
+ String errorUri;
+ try {
+ errorUri = getUnknownProtocolErrorPageUri(URLEncoder.encode(uri, "UTF-8"));
+ } catch (final UnsupportedEncodingException encodingE) {
+ errorUri = getUnknownProtocolErrorPageUri("");
+ }
+
+ // Don't log the exception to prevent leaking URIs.
+ Log.w(LOGTAG, "Unable to parse Intent URI - loading about:neterror");
+ callback.sendError(errorUri);
+ return;
+ }
+
+ // For this flow, we follow Chrome's lead:
+ // https://developer.chrome.com/multidevice/android/intents
+ final String fallbackUrl = intent.getStringExtra(EXTRA_BROWSER_FALLBACK_URL);
+ if (isFallbackUrlValid(fallbackUrl)) {
+ // Opens the page in JS.
+ callback.sendError(fallbackUrl);
+
+ } else if (intent.getPackage() != null) {
+ // Note on alternative flows: we could get the intent package from a component, however, for
+ // security reasons, components are ignored when opening URIs (bug 1168998) so we should
+ // ignore it here too.
+ //
+ // Our old flow used to prompt the user to search for their app in the market by scheme and
+ // while this could help the user find a new app, there is not always a correlation in
+ // scheme to application name and we could end up steering the user wrong (potentially to
+ // malicious software). Better to leave that one alone.
+ final String marketUri = MARKET_INTENT_URI_PACKAGE_PREFIX + intent.getPackage();
+ final Intent marketIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(marketUri));
+ marketIntent.addCategory(Intent.CATEGORY_BROWSABLE);
+ marketIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+
+ // (Bug 1192436) We don't know if marketIntent matches any Activities (e.g. non-Play
+ // Store devices). If it doesn't, clicking the link will cause no action to occur.
+ ExternalIntentDuringPrivateBrowsingPromptFragment.showDialogOrAndroidChooser(
+ activity, activity.getSupportFragmentManager(), marketIntent);
+ callback.sendSuccess(null);
+
+ } else {
+ // We return the error page here, but it will only be shown if we think the load did
+ // not come from clicking a link. Chrome does not show error pages in that case, and
+ // many websites have catered to this behavior. For example, the site might set a timeout and load a play
+ // store url for their app if the intent link fails to load, i.e. the app is not installed.
+ // These work-arounds would often end with our users seeing about:neterror instead of the intended experience.
+ // While I feel showing about:neterror is a better solution for users (when not hacked around),
+ // we should match the status quo for the good of our users.
+ //
+ // Don't log the URI to prevent leaking it.
+ Log.w(LOGTAG, "Unable to open URI, maybe showing neterror");
+ callback.sendError(getUnknownProtocolErrorPageUri(intent.getData().toString()));
+ }
+ }
+
+ private static boolean isFallbackUrlValid(@Nullable final String fallbackUrl) {
+ if (fallbackUrl == null) {
+ return false;
+ }
+
+ try {
+ final String anyCaseScheme = new URI(fallbackUrl).getScheme();
+ final String scheme = (anyCaseScheme == null) ? null : anyCaseScheme.toLowerCase(Locale.US);
+ if ("http".equals(scheme) || "https".equals(scheme)) {
+ return true;
+ } else {
+ Log.w(LOGTAG, "Fallback URI uses unsupported scheme: " + scheme + ". Try http or https.");
+ }
+ } catch (final URISyntaxException e) {
+ // Do not include Exception to avoid leaking uris.
+ Log.w(LOGTAG, "URISyntaxException parsing fallback URI");
+ }
+ return false;
+ }
+
+ /**
+ * Returns an about:neterror uri with the unknownProtocolFound text as a parameter.
+ * @param encodedUri The encoded uri. While the page does not open correctly without specifying
+ * a uri parameter, it happily accepts the empty String so this argument may
+ * be the empty String.
+ */
+ private String getUnknownProtocolErrorPageUri(final String encodedUri) {
+ return UNKNOWN_PROTOCOL_URI_PREFIX + encodedUri;
+ }
+
+ private static class ResultHandler implements ActivityResultHandler {
+ private final JSONObject message;
+
+ public ResultHandler(JSONObject message) {
+ this.message = message;
+ }
+
+ @Override
+ public void onActivityResult(int resultCode, Intent data) {
+ JSONObject response = new JSONObject();
+ try {
+ if (data != null) {
+ if (data.getExtras() != null) {
+ response.put("extras", JSONUtils.bundleToJSON(data.getExtras()));
+ }
+ if (data.getData() != null) {
+ response.put("uri", data.getData().toString());
+ }
+ }
+ response.put("resultCode", resultCode);
+ } catch (JSONException e) {
+ Log.w(LOGTAG, "Error building JSON response.", e);
+ }
+ EventDispatcher.sendResponse(message, response);
+ }
+ }
+}