| // Copyright 2017 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| package org.chromium.content.browser.installedapp; |
| |
| import android.content.Context; |
| import android.content.pm.ApplicationInfo; |
| import android.content.pm.PackageManager; |
| import android.content.pm.PackageManager.NameNotFoundException; |
| import android.content.res.Resources; |
| import android.os.AsyncTask; |
| import android.util.Pair; |
| |
| import org.json.JSONArray; |
| import org.json.JSONException; |
| import org.json.JSONObject; |
| |
| import org.chromium.base.Log; |
| import org.chromium.base.ThreadUtils; |
| import org.chromium.base.VisibleForTesting; |
| import org.chromium.installedapp.mojom.InstalledAppProvider; |
| import org.chromium.installedapp.mojom.RelatedApplication; |
| import org.chromium.mojo.system.MojoException; |
| |
| import java.net.URI; |
| import java.net.URISyntaxException; |
| import java.util.ArrayList; |
| |
| /** |
| * Android implementation of the InstalledAppProvider service defined in |
| * installed_app_provider.mojom |
| */ |
| public class InstalledAppProviderImpl implements InstalledAppProvider { |
| @VisibleForTesting |
| public static final String ASSET_STATEMENTS_KEY = "asset_statements"; |
| private static final String ASSET_STATEMENT_FIELD_TARGET = "target"; |
| private static final String ASSET_STATEMENT_FIELD_NAMESPACE = "namespace"; |
| private static final String ASSET_STATEMENT_FIELD_SITE = "site"; |
| @VisibleForTesting |
| public static final String ASSET_STATEMENT_NAMESPACE_WEB = "web"; |
| @VisibleForTesting |
| public static final String RELATED_APP_PLATFORM_ANDROID = "play"; |
| |
| private static final String TAG = "InstalledAppProvider"; |
| |
| private final FrameUrlDelegate mFrameUrlDelegate; |
| private final Context mContext; |
| |
| /** |
| * Small interface for dynamically getting the URL of the current frame. |
| * |
| * Abstract to allow for testing. |
| */ |
| public static interface FrameUrlDelegate { |
| /** |
| * Gets the URL of the current frame. Can return null (if the frame has disappeared). |
| */ |
| public URI getUrl(); |
| |
| /** |
| * Checks if we're in incognito. If the frame has disappeared this returns true. |
| */ |
| public boolean isIncognito(); |
| } |
| |
| public InstalledAppProviderImpl(FrameUrlDelegate frameUrlDelegate, Context context) { |
| mFrameUrlDelegate = frameUrlDelegate; |
| mContext = context; |
| } |
| |
| @Override |
| public void filterInstalledApps( |
| final RelatedApplication[] relatedApps, final FilterInstalledAppsResponse callback) { |
| if (mFrameUrlDelegate.isIncognito()) { |
| callback.call(new RelatedApplication[0]); |
| return; |
| } |
| |
| final URI frameUrl = mFrameUrlDelegate.getUrl(); |
| |
| // Use an AsyncTask to execute the installed/related checks on a background thread (so as |
| // not to block the UI thread). |
| new AsyncTask<Void, Void, Pair<RelatedApplication[], Integer>>() { |
| @Override |
| protected Pair<RelatedApplication[], Integer> doInBackground(Void... unused) { |
| return filterInstalledAppsOnBackgroundThread(relatedApps, frameUrl); |
| } |
| |
| @Override |
| protected void onPostExecute(Pair<RelatedApplication[], Integer> result) { |
| final RelatedApplication[] installedApps = result.first; |
| int delayMillis = result.second; |
| // Before calling the callback, delay for the amount of time that has been |
| // calculated in |delayMillis|. |
| delayThenRun(new Runnable() { |
| @Override |
| public void run() { |
| callback.call(installedApps); |
| } |
| }, delayMillis); |
| } |
| }.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); |
| } |
| |
| @Override |
| public void close() {} |
| |
| @Override |
| public void onConnectionError(MojoException e) {} |
| |
| /** |
| * Filters a list of apps, returning those that are both installed and match the origin. |
| * |
| * This method is expected to be called on a background thread (not the main UI thread). |
| * |
| * @param relatedApps A list of applications to be filtered. |
| * @param frameUrl The URL of the frame this operation was called from. |
| * @return Pair of: A subsequence of applications that meet the criteria, and, the total amount |
| * of time in ms that should be delayed before returning to the user, to mask the |
| * installed state of the requested apps. |
| */ |
| private Pair<RelatedApplication[], Integer> filterInstalledAppsOnBackgroundThread( |
| RelatedApplication[] relatedApps, URI frameUrl) { |
| ArrayList<RelatedApplication> installedApps = new ArrayList<RelatedApplication>(); |
| int delayMillis = 0; |
| PackageManager pm = mContext.getPackageManager(); |
| for (RelatedApplication app : relatedApps) { |
| // If the package is of type "play", it is installed, and the origin is associated with |
| // package, add the package to the list of valid packages. |
| // NOTE: For security, it must not be possible to distinguish (from the response) |
| // between the app not being installed and the origin not being associated with the app |
| // (otherwise, arbitrary websites would be able to test whether un-associated apps are |
| // installed on the user's device). |
| if (app.platform.equals(RELATED_APP_PLATFORM_ANDROID) && app.id != null) { |
| delayMillis += calculateDelayForPackageMs(app.id); |
| if (isAppInstalledAndAssociatedWithOrigin(app.id, frameUrl, pm)) { |
| installedApps.add(app); |
| } |
| } |
| } |
| |
| RelatedApplication[] installedAppsArray = new RelatedApplication[installedApps.size()]; |
| installedApps.toArray(installedAppsArray); |
| return Pair.create(installedAppsArray, delayMillis); |
| } |
| |
| /** |
| * Determines how long to artifically delay for, for a particular package name. |
| */ |
| private int calculateDelayForPackageMs(String packageName) { |
| // Important timing-attack prevention measure: delay by a pseudo-random amount of time, to |
| // add significant noise to the time taken to check whether this app is installed and |
| // related. Otherwise, it would be possible to tell whether a non-related app is installed, |
| // based on the time this operation takes. |
| // |
| // Generate a 16-bit hash based on a unique device ID + the package name. |
| short hash = PackageHash.hashForPackage(packageName); |
| |
| // The time delay is the low 10 bits of the hash in 100ths of a ms (between 0 and 10ms). |
| int delayHundredthsOfMs = hash & 0x3ff; |
| return delayHundredthsOfMs / 100; |
| } |
| |
| /** |
| * Determines whether a particular app is installed and matches the origin. |
| * |
| * @param packageName Name of the Android package to check if installed. Returns false if the |
| * app is not installed. |
| * @param frameUrl Returns false if the Android package does not declare association with the |
| * origin of this URL. Can be null. |
| */ |
| private boolean isAppInstalledAndAssociatedWithOrigin( |
| String packageName, URI frameUrl, PackageManager pm) { |
| if (frameUrl == null) return false; |
| |
| // Early-exit if the Android app is not installed. |
| JSONArray statements; |
| try { |
| statements = getAssetStatements(packageName, pm); |
| } catch (NameNotFoundException e) { |
| return false; |
| } |
| |
| // The installed Android app has provided us with a list of asset statements. If any one of |
| // those statements is a web asset that matches the given origin, return true. |
| for (int i = 0; i < statements.length(); i++) { |
| JSONObject statement; |
| try { |
| statement = statements.getJSONObject(i); |
| } catch (JSONException e) { |
| // If an element is not an object, just ignore it. |
| continue; |
| } |
| |
| URI site = getSiteForWebAsset(statement); |
| |
| // The URI is considered equivalent if the scheme, host, and port match, according |
| // to the DigitalAssetLinks v1 spec. |
| if (site != null && statementTargetMatches(frameUrl, site)) { |
| return true; |
| } |
| } |
| |
| // No asset matched the origin. |
| return false; |
| } |
| |
| /** |
| * Gets the asset statements from an Android app's manifest. |
| * |
| * This retrieves the list of statements from the Android app's "asset_statements" manifest |
| * resource, as specified in Digital Asset Links v1. |
| * |
| * @param packageName Name of the Android package to get statements from. |
| * @return The list of asset statements, parsed from JSON. |
| * @throws NameNotFoundException if the application is not installed. |
| */ |
| private static JSONArray getAssetStatements(String packageName, PackageManager pm) |
| throws NameNotFoundException { |
| // Get the <meta-data> from this app's manifest. |
| // Throws NameNotFoundException if the application is not installed. |
| ApplicationInfo appInfo = pm.getApplicationInfo(packageName, PackageManager.GET_META_DATA); |
| int identifier = appInfo.metaData.getInt(ASSET_STATEMENTS_KEY); |
| if (identifier == 0) { |
| return new JSONArray(); |
| } |
| |
| // Throws NameNotFoundException in the rare case that the application was uninstalled since |
| // getting |appInfo| (or resources could not be loaded for some other reason). |
| Resources resources = pm.getResourcesForApplication(appInfo); |
| |
| String statements; |
| try { |
| statements = resources.getString(identifier); |
| } catch (Resources.NotFoundException e) { |
| // This should never happen, but it could if there was a broken APK, so handle it |
| // gracefully without crashing. |
| Log.w(TAG, |
| "Android package " + packageName + " missing asset statements resource (0x" |
| + Integer.toHexString(identifier) + ")."); |
| return new JSONArray(); |
| } |
| |
| try { |
| return new JSONArray(statements); |
| } catch (JSONException e) { |
| // If the JSON is invalid or not an array, assume it is empty. |
| Log.w(TAG, |
| "Android package " + packageName |
| + " has JSON syntax error in asset statements resource (0x" |
| + Integer.toHexString(identifier) + ")."); |
| return new JSONArray(); |
| } |
| } |
| |
| /** |
| * Gets the "site" URI from an Android asset statement. |
| * |
| * @return The site, or null if the asset string was invalid or not related to a web site. This |
| * could be because: the JSON string was invalid, there was no "target" field, this was |
| * not a web asset, there was no "site" field, or the "site" field was invalid. |
| */ |
| private static URI getSiteForWebAsset(JSONObject statement) { |
| JSONObject target; |
| try { |
| // Ignore the "relation" field and allow an asset with any relation to this origin. |
| // TODO(mgiuca): [Spec issue] Should we require a specific relation string, rather |
| // than any or no relation? |
| target = statement.getJSONObject(ASSET_STATEMENT_FIELD_TARGET); |
| } catch (JSONException e) { |
| return null; |
| } |
| |
| // If it is not a web asset, skip it. |
| if (!isAssetWeb(target)) { |
| return null; |
| } |
| |
| try { |
| return new URI(target.getString(ASSET_STATEMENT_FIELD_SITE)); |
| } catch (JSONException | URISyntaxException e) { |
| return null; |
| } |
| } |
| |
| /** |
| * Determines whether an Android asset statement is for a website. |
| * |
| * @param target The "target" field of the asset statement. |
| */ |
| private static boolean isAssetWeb(JSONObject target) { |
| String namespace; |
| try { |
| namespace = target.getString(ASSET_STATEMENT_FIELD_NAMESPACE); |
| } catch (JSONException e) { |
| return false; |
| } |
| |
| return namespace.equals(ASSET_STATEMENT_NAMESPACE_WEB); |
| } |
| |
| private static boolean statementTargetMatches(URI frameUrl, URI assetUrl) { |
| if (assetUrl.getScheme() == null || assetUrl.getAuthority() == null) { |
| return false; |
| } |
| |
| return assetUrl.getScheme().equals(frameUrl.getScheme()) |
| && assetUrl.getAuthority().equals(frameUrl.getAuthority()); |
| } |
| |
| /** |
| * Runs a Runnable task after a given delay. |
| * |
| * Protected and non-static for testing. |
| * |
| * @param r The Runnable that will be executed. |
| * @param delayMillis The delay (in ms) until the Runnable will be executed. |
| * @return True if the Runnable was successfully placed into the message queue. |
| */ |
| protected void delayThenRun(Runnable r, long delayMillis) { |
| ThreadUtils.postOnUiThreadDelayed(r, delayMillis); |
| } |
| } |