/* -*- 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 getOpenURIIntent, 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 * Intent.ACTION_SEND. * @param title the title to use in ACTION_SEND 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 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 Context 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 ACTION_SEND intents. * @return an Intent, or null 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 Context 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 * Intent.ACTION_SEND. * @param title the title to use in ACTION_SEND intents. * @return an Intent, or null 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; } final Uri data = intent.getData(); if (data != null && "file".equals(data.normalizeScheme().getScheme())) { Log.w(LOGTAG, "Blocked intent with \"file://\" data scheme."); 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 Uri instance which is equivalent to u, * but with a guaranteed-lowercase scheme as if the API level 16 method * u.normalizeScheme had been called. * * @param u the Uri to normalize. * @return a Uri, which might be u. */ 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 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); } } }