chromium/chrome/android/java/src/org/chromium/chrome/browser/browserservices/InstalledWebappBroadcastReceiver.java

// Copyright 2018 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.browserservices;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;

import org.chromium.base.Log;
import org.chromium.base.version_info.VersionInfo;
import org.chromium.chrome.browser.ChromeApplicationImpl;
import org.chromium.chrome.browser.browserservices.permissiondelegation.PermissionUpdater;
import org.chromium.chrome.browser.webapps.WebApkUninstallTracker;
import org.chromium.components.embedder_support.util.Origin;
import org.chromium.components.webapk.lib.common.WebApkConstants;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;

import javax.inject.Inject;

/**
 * A {@link android.content.BroadcastReceiver} that detects when an installed webapp (TWA or WebAPK)
 * has been uninstalled or has had its data cleared. When this happens we clear Chrome's data
 * corresponding to that app.
 *
 * Trusted Web Activities are registered to an origin (eg https://www.example.com), however because
 * cookies can be scoped more loosely, at eTLD+1 (or domain) level (eg *.example.com) [1], we need
 * to clear data at that level. This unfortunately can lead to too much data getting cleared - for
 * example if the https://maps.google.com TWA is cleared, you'll loose cookies for
 * https://mail.google.com too (since they both share the google.com domain).
 *
 * We find this acceptable for two reasons:
 * - The alternative is *not* clearing some related data - eg a TWA linked to
 *   https://maps.google.com sets a cookie with Domain=google.com. The TWA is uninstalled and
 *   reinstalled and it can access the cookie it stored before.
 * - We ask the user before clearing the data and while doing so display the scope of data we're
 *   going to wipe.
 *
 * [1] https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Scope_of_cookies
 *
 * Lifecycle: The lifecycle of this class is managed by Android.
 * Thread safety: {@link #onReceive} will be called on the UI thread.
 */
public class InstalledWebappBroadcastReceiver extends BroadcastReceiver {
    private static final String TAG = "IWBroadcastReceiver";

    /**
     * An Action that will trigger clearing data on local builds only, for development. The adb
     * command to trigger is:
     * adb shell am broadcast \
     *   -n com.google.android.apps.chrome/\
     * org.chromium.chrome.browser.browserservices.InstalledWebappBroadcastReceiver \
     *   -a org.chromium.chrome.browser.browserservices.InstalledWebappBroadcastReceiver.DEBUG \
     *   --ei android.intent.extra.UID 23
     *
     * But replace 23 with the uid of a Trusted Web Activity Client app.
     */
    private static final String ACTION_DEBUG =
            "org.chromium.chrome.browser.browserservices.InstalledWebappBroadcastReceiver.DEBUG";

    private static final Set<String> BROADCASTS =
            new HashSet<>(
                    Arrays.asList(
                            Intent.ACTION_PACKAGE_DATA_CLEARED,
                            Intent.ACTION_PACKAGE_FULLY_REMOVED));

    private final ClearDataStrategy mClearDataStrategy;
    private final InstalledWebappDataRegister mDataRegister;
    private final BrowserServicesStore mStore;
    private final PermissionUpdater mPermissionUpdater;

    /** Constructor with default dependencies for Android. */
    @Inject
    public InstalledWebappBroadcastReceiver() {
        this(
                new ClearDataStrategy(),
                new InstalledWebappDataRegister(),
                new BrowserServicesStore(
                        ChromeApplicationImpl.getComponent().resolveChromeSharedPreferences()),
                ChromeApplicationImpl.getComponent().resolvePermissionUpdater());
    }

    /** Constructor to allow dependency injection in tests. */
    public InstalledWebappBroadcastReceiver(
            ClearDataStrategy strategy,
            InstalledWebappDataRegister dataRegister,
            BrowserServicesStore store,
            PermissionUpdater permissionUpdater) {
        mClearDataStrategy = strategy;
        mDataRegister = dataRegister;
        mStore = store;
        mPermissionUpdater = permissionUpdater;
    }

    @Override
    public void onReceive(Context context, Intent intent) {
        if (intent == null) return;
        // Since we only care about ACTION_PACKAGE_DATA_CLEARED and and ACTION_PACKAGE_FULLY_REMOVED
        // which are protected Intents, we can assume that anything that gets past here will be a
        // legitimate Intent sent by the system.
        boolean debug = VersionInfo.isLocalBuild() && ACTION_DEBUG.equals(intent.getAction());
        if (!debug && !BROADCASTS.contains(intent.getAction())) return;

        int uid = intent.getIntExtra(Intent.EXTRA_UID, -1);
        if (uid == -1) return;

        boolean uninstalled = Intent.ACTION_PACKAGE_FULLY_REMOVED.equals(intent.getAction());

        if (uninstalled && intent.getData() != null) {
            String packageName = intent.getData().getSchemeSpecificPart();
            if (packageName != null
                    && packageName.startsWith(WebApkConstants.WEBAPK_PACKAGE_PREFIX)) {
                // Native is likely not loaded. Defer recording UMA and UKM till the next browser
                // launch.
                WebApkUninstallTracker.deferRecordWebApkUninstalled(packageName);
            }
        }

        // The {@link InstalledWebappDataRegister} (because it uses Preferences) is loaded
        // lazily, so to time opening the file we must include the first read as well.
        if (!mDataRegister.chromeHoldsDataForPackage(uid)) {
            Log.d(TAG, "Chrome holds no data for package.");
            return;
        }

        mClearDataStrategy.execute(context, mDataRegister, mPermissionUpdater, uid, uninstalled);
        clearPreferences(uid, uninstalled);
    }

    private void clearPreferences(int uid, boolean uninstalled) {
        String packageName = mDataRegister.getPackageNameForRegisteredUid(uid);
        mStore.removeTwaDisclosureAcceptanceForPackage(packageName);
        if (uninstalled) {
            mDataRegister.removePackage(uid);
        }
    }

    /** Implemented as a class partially for historic reasons, partially to help testing. */
    static class ClearDataStrategy {
        public void execute(
                Context context,
                InstalledWebappDataRegister dataRegister,
                PermissionUpdater permissionUpdater,
                int uid,
                boolean uninstalled) {
            // Retrieving domains and origins ahead of time, because the register is about to be
            // cleaned up.
            Set<String> domains = dataRegister.getDomainsForRegisteredUid(uid);
            Set<String> origins = dataRegister.getOriginsForRegisteredUid(uid);

            for (String originAsString : origins) {
                Origin origin = Origin.create(originAsString);
                if (origin != null) permissionUpdater.onClientAppUninstalled(origin);
            }

            String appName = dataRegister.getAppNameForRegisteredUid(uid);
            Intent intent =
                    ClearDataDialogActivity.createIntent(
                            context, appName, domains, origins, uninstalled);
            intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_NEW_DOCUMENT);
            context.startActivity(intent);
        }
    }
}