chromium/chrome/android/java/src/org/chromium/chrome/browser/browserservices/TrustedWebActivityClient.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 static org.chromium.chrome.browser.browserservices.permissiondelegation.InstalledWebappGeolocationBridge.EXTRA_NEW_LOCATION_ERROR_CALLBACK;

import android.app.ActivityOptions;
import android.app.Notification;
import android.app.PendingIntent;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.os.Messenger;
import android.os.RemoteException;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.browser.trusted.Token;
import androidx.browser.trusted.TrustedWebActivityCallback;
import androidx.browser.trusted.TrustedWebActivityService;
import androidx.browser.trusted.TrustedWebActivityServiceConnectionPool;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ChromeApplicationImpl;
import org.chromium.chrome.browser.browserservices.TrustedWebActivityClientWrappers.Connection;
import org.chromium.chrome.browser.browserservices.TrustedWebActivityClientWrappers.ConnectionPool;
import org.chromium.chrome.browser.browserservices.metrics.TrustedWebActivityUmaRecorder;
import org.chromium.chrome.browser.browserservices.metrics.TrustedWebActivityUmaRecorder.DelegatedNotificationSmallIconFallback;
import org.chromium.chrome.browser.browserservices.permissiondelegation.InstalledWebappPermissionManager;
import org.chromium.chrome.browser.browserservices.permissiondelegation.PermissionStatus;
import org.chromium.chrome.browser.notifications.NotificationBuilderBase;
import org.chromium.chrome.browser.notifications.NotificationUmaTracker;
import org.chromium.components.browser_ui.notifications.NotificationMetadata;
import org.chromium.components.content_settings.ContentSettingValues;
import org.chromium.components.content_settings.ContentSettingsType;
import org.chromium.components.embedder_support.util.Origin;
import org.chromium.components.embedder_support.util.UrlConstants;

import java.util.List;
import java.util.Set;

import javax.inject.Inject;
import javax.inject.Singleton;

/** A client for calling methods on a {@link TrustedWebActivityService}. */
@Singleton
public class TrustedWebActivityClient {
    private static final String TAG = "TWAClient";

    private static final String CHECK_LOCATION_PERMISSION_COMMAND_NAME =
            "checkAndroidLocationPermission";
    private static final String LOCATION_PERMISSION_RESULT = "locationPermissionResult";
    private static final String EXTRA_COMMAND_SUCCESS = "success";

    private static final String START_LOCATION_COMMAND_NAME = "startLocation";
    private static final String STOP_LOCATION_COMMAND_NAME = "stopLocation";
    private static final String LOCATION_ARG_ENABLE_HIGH_ACCURACY = "enableHighAccuracy";

    private static final String COMMAND_CHECK_NOTIFICATION_PERMISSION =
            "checkNotificationPermission";
    private static final String COMMAND_GET_NOTIFICATION_PERMISSION_REQUEST_PENDING_INTENT =
            "getNotificationPermissionRequestPendingIntent";
    private static final String ARG_NOTIFICATION_CHANNEL_NAME = "notificationChannelName";
    private static final String KEY_PERMISSION_STATUS = "permissionStatus";
    private static final String KEY_NOTIFICATION_PERMISSION_REQUEST_PENDING_INTENT =
            "notificationPermissionRequestPendingIntent";
    private static final String EXTRA_MESSENGER = "messenger";

    private final ConnectionPool mConnectionPool;
    private final InstalledWebappPermissionManager mPermissionManager;
    private final TrustedWebActivityUmaRecorder mRecorder;

    /** Interface for callbacks to get a permission setting from a TWA app. */
    public interface PermissionCallback {
        /** Called when the app answered with a permission setting. */
        void onPermission(ComponentName app, @ContentSettingValues int settingValue);

        /** Called when no app was found to connect to. */
        default void onNoTwaFound() {}
    }

    /** Interface for callbacks to {@link #connectAndExecute}. */
    public interface ExecutionCallback {
        void onConnected(Origin origin, Connection service) throws RemoteException;

        void onNoTwaFound();
    }

    /** Creates a TrustedWebActivityClient. */
    @Inject
    public TrustedWebActivityClient(
            TrustedWebActivityServiceConnectionPool connectionPool,
            InstalledWebappPermissionManager permissionManager,
            TrustedWebActivityUmaRecorder recorder) {
        this(TrustedWebActivityClientWrappers.wrap(connectionPool), permissionManager, recorder);
    }

    /** Creates a TrustedWebActivityClient for tests. */
    public TrustedWebActivityClient(
            ConnectionPool connectionPool,
            InstalledWebappPermissionManager permissionManager,
            TrustedWebActivityUmaRecorder recorder) {
        mConnectionPool = connectionPool;
        mPermissionManager = permissionManager;
        mRecorder = recorder;
    }

    /**
     * Whether a Trusted Web Activity client is available to display notifications of the given
     * scope.
     * @param scope The scope of the Service Worker that triggered the notification.
     * @return Whether a Trusted Web Activity client was found to show the notification.
     */
    public boolean twaExistsForScope(Uri scope) {
        Origin origin = Origin.create(scope);
        if (origin == null) return false;
        Set<Token> possiblePackages = mPermissionManager.getAllDelegateApps(origin);
        if (possiblePackages == null) return false;
        return mConnectionPool.serviceExistsForScope(scope, possiblePackages);
    }

    /**
     * Gets the notification permission setting of the TWA for the given origin.
     * @param url The url of the web page that requesting the permission.
     * @param permissionCallback To be called with the permission setting.
     */
    public void checkNotificationPermission(String url, PermissionCallback permissionCallback) {
        String channelName =
                ContextUtils.getApplicationContext()
                        .getResources()
                        .getString(R.string.notification_category_group_general);

        connectAndExecute(
                Uri.parse(url),
                new ExecutionCallback() {
                    @Override
                    public void onConnected(Origin origin, Connection service)
                            throws RemoteException {
                        Bundle commandArgs = new Bundle();
                        commandArgs.putString(ARG_NOTIFICATION_CHANNEL_NAME, channelName);
                        Bundle commandResult =
                                safeSendExtraCommand(
                                        service,
                                        COMMAND_CHECK_NOTIFICATION_PERMISSION,
                                        commandArgs,
                                        /* callback= */ null);
                        boolean commandSuccess =
                                commandResult == null
                                        ? false
                                        : commandResult.getBoolean(EXTRA_COMMAND_SUCCESS);
                        mRecorder.recordExtraCommandSuccess(
                                COMMAND_CHECK_NOTIFICATION_PERMISSION, commandSuccess);
                        // The command might fail if the app is too old to support it. To handle
                        // that case, fall back to the old flow.
                        if (!commandSuccess) {
                            boolean enabled = service.areNotificationsEnabled(channelName);
                            @ContentSettingValues
                            int settingValue =
                                    enabled
                                            ? ContentSettingValues.ALLOW
                                            : ContentSettingValues.BLOCK;
                            permissionCallback.onPermission(
                                    service.getComponentName(), settingValue);
                            return;
                        }

                        @ContentSettingValues int settingValue = ContentSettingValues.BLOCK;
                        @PermissionStatus
                        int permissionStatus =
                                commandResult.getInt(KEY_PERMISSION_STATUS, PermissionStatus.BLOCK);
                        if (permissionStatus == PermissionStatus.ALLOW) {
                            settingValue = ContentSettingValues.ALLOW;
                        } else if (permissionStatus == PermissionStatus.ASK) {
                            settingValue = ContentSettingValues.ASK;
                        }
                        permissionCallback.onPermission(service.getComponentName(), settingValue);
                    }

                    @Override
                    public void onNoTwaFound() {
                        Log.w(TAG, "Unable to check notification permission.");
                        permissionCallback.onNoTwaFound();
                    }
                });
    }

    /**
     * Requests notification permission for the TWA for the given url using a dialog.
     * @param url The url of the web page that requesting the permission.
     * @param permissionCallback To be called with the permission setting.
     */
    public void requestNotificationPermission(String url, PermissionCallback permissionCallback) {
        String channelName =
                ContextUtils.getApplicationContext()
                        .getResources()
                        .getString(R.string.notification_category_group_general);
        connectAndExecute(
                Uri.parse(url),
                new ExecutionCallback() {
                    @Override
                    public void onConnected(Origin origin, Connection service)
                            throws RemoteException {
                        Bundle commandArgs = new Bundle();
                        commandArgs.putString(ARG_NOTIFICATION_CHANNEL_NAME, channelName);
                        Bundle commandResult =
                                safeSendExtraCommand(
                                        service,
                                        COMMAND_GET_NOTIFICATION_PERMISSION_REQUEST_PENDING_INTENT,
                                        commandArgs,
                                        /* callback= */ null);
                        boolean commandSuccess =
                                commandResult == null
                                        ? false
                                        : commandResult.getBoolean(EXTRA_COMMAND_SUCCESS);
                        PendingIntent pendingIntent =
                                commandSuccess
                                        ? commandResult.getParcelable(
                                                KEY_NOTIFICATION_PERMISSION_REQUEST_PENDING_INTENT)
                                        : null;
                        mRecorder.recordExtraCommandSuccess(
                                COMMAND_GET_NOTIFICATION_PERMISSION_REQUEST_PENDING_INTENT,
                                commandSuccess && pendingIntent != null);
                        if (!commandSuccess || pendingIntent == null) {
                            permissionCallback.onPermission(
                                    service.getComponentName(), ContentSettingValues.BLOCK);
                            return;
                        }

                        Handler handler =
                                new Handler(
                                        Looper.getMainLooper(),
                                        message -> {
                                            @ContentSettingValues
                                            int settingValue = ContentSettingValues.BLOCK;
                                            @PermissionStatus
                                            int permissionStatus =
                                                    message.getData()
                                                            .getInt(
                                                                    KEY_PERMISSION_STATUS,
                                                                    PermissionStatus.BLOCK);
                                            if (permissionStatus == PermissionStatus.ALLOW) {
                                                settingValue = ContentSettingValues.ALLOW;
                                            } else if (permissionStatus == PermissionStatus.ASK) {
                                                settingValue = ContentSettingValues.ASK;
                                            }
                                            permissionCallback.onPermission(
                                                    service.getComponentName(), settingValue);
                                            return true;
                                        });
                        Intent extraIntent = new Intent();
                        extraIntent.putExtra(EXTRA_MESSENGER, new Messenger(handler));
                        try {
                            ActivityOptions options = ActivityOptions.makeBasic();
                            ApiCompatibilityUtils.setActivityOptionsBackgroundActivityStartMode(
                                    options);
                            pendingIntent.send(
                                    ContextUtils.getApplicationContext(),
                                    0,
                                    extraIntent,
                                    null,
                                    null,
                                    null,
                                    options.toBundle());
                        } catch (PendingIntent.CanceledException e) {
                            Log.e(TAG, "The PendingIntent was canceled.", e);
                        }
                    }

                    @Override
                    public void onNoTwaFound() {
                        Log.w(TAG, "Unable to request notification permission.");
                        permissionCallback.onNoTwaFound();
                    }
                });
    }

    /**
     * Check location permission for the TWA of the given origin.
     * @param url The url of the web page that requesting the permission.
     * @param permissionCallback Will be called with whether the permission is granted.
     */
    public void checkLocationPermission(String url, PermissionCallback permissionCallback) {
        connectAndExecute(
                Uri.parse(url),
                new ExecutionCallback() {
                    @Override
                    public void onConnected(Origin origin, Connection service)
                            throws RemoteException {
                        TrustedWebActivityCallback resultCallback =
                                new TrustedWebActivityCallback() {
                                    private void onUiThread(
                                            String callbackName, @Nullable Bundle bundle) {
                                        boolean granted =
                                                CHECK_LOCATION_PERMISSION_COMMAND_NAME.equals(
                                                                callbackName)
                                                        && bundle != null
                                                        && bundle.getBoolean(
                                                                LOCATION_PERMISSION_RESULT);
                                        @ContentSettingValues
                                        int settingValue =
                                                granted
                                                        ? ContentSettingValues.ALLOW
                                                        : ContentSettingValues.BLOCK;
                                        permissionCallback.onPermission(
                                                service.getComponentName(), settingValue);
                                    }

                                    @Override
                                    public void onExtraCallback(
                                            String callbackName, @Nullable Bundle bundle) {
                                        // Hop back to the UI thread because we are on a binder
                                        // thread.
                                        PostTask.postTask(
                                                TaskTraits.UI_USER_VISIBLE,
                                                () -> onUiThread(callbackName, bundle));
                                    }
                                };
                        Bundle executionResult =
                                safeSendExtraCommand(
                                        service,
                                        CHECK_LOCATION_PERMISSION_COMMAND_NAME,
                                        Bundle.EMPTY,
                                        resultCallback);
                        // Set permission to false if the service does not know how to handle the
                        // extraCommand or did not handle the command.
                        if (executionResult == null
                                || !executionResult.getBoolean(EXTRA_COMMAND_SUCCESS)) {
                            permissionCallback.onPermission(
                                    service.getComponentName(), ContentSettingValues.BLOCK);
                        }
                    }

                    @Override
                    public void onNoTwaFound() {
                        Log.w(TAG, "Unable to request location permission.");
                        permissionCallback.onNoTwaFound();
                    }
                });
    }

    /**
     * Start listening for location updates. Location updates are passed to the callback on a
     * background thread. Errors may be passed on the main thread or on a background thread,
     * depending on where and when they happen.
     */
    public void startListeningLocationUpdates(
            String url, boolean highAccuracy, TrustedWebActivityCallback locationCallback) {
        connectAndExecute(
                Uri.parse(url),
                new ExecutionCallback() {
                    @Override
                    public void onConnected(Origin origin, Connection service)
                            throws RemoteException {
                        Bundle args = new Bundle();
                        args.putBoolean(LOCATION_ARG_ENABLE_HIGH_ACCURACY, highAccuracy);
                        Bundle executionResult =
                                safeSendExtraCommand(
                                        service,
                                        START_LOCATION_COMMAND_NAME,
                                        args,
                                        locationCallback);
                        // Notify an error if the service does not know how to handle the
                        // extraCommand.
                        if (executionResult == null
                                || !executionResult.getBoolean(EXTRA_COMMAND_SUCCESS)) {
                            notifyLocationUpdateError(
                                    locationCallback, "Failed to request location updates");
                        }
                    }

                    @Override
                    public void onNoTwaFound() {
                        Log.w(TAG, "Unable to start listening for location.");
                        notifyLocationUpdateError(locationCallback, "NoTwaFound");
                    }
                });
    }

    public void stopLocationUpdates(String url) {
        connectAndExecute(
                Uri.parse(url),
                new ExecutionCallback() {
                    @Override
                    public void onConnected(Origin origin, Connection service)
                            throws RemoteException {
                        safeSendExtraCommand(
                                service, STOP_LOCATION_COMMAND_NAME, Bundle.EMPTY, null);
                    }

                    @Override
                    public void onNoTwaFound() {
                        Log.w(TAG, "Unable to stop listening for location.");
                    }
                });
    }

    /**
     * Displays a notification through a Trusted Web Activity client.
     * @param scope The scope of the Service Worker that triggered the notification.
     * @param platformTag A notification tag.
     * @param platformId A notification id.
     * @param builder A builder for the notification to display.
     *                The Trusted Web Activity client may override the small icon.
     * @param notificationUmaTracker To log Notification UMA.
     */
    public void notifyNotification(
            Uri scope,
            String platformTag,
            int platformId,
            NotificationBuilderBase builder,
            NotificationUmaTracker notificationUmaTracker) {
        Resources res = ContextUtils.getApplicationContext().getResources();
        String channelDisplayName = res.getString(R.string.notification_category_group_general);

        connectAndExecute(
                scope,
                new ExecutionCallback() {
                    @Override
                    public void onConnected(Origin origin, Connection service)
                            throws RemoteException {
                        if (!service.areNotificationsEnabled(channelDisplayName)) {
                            mPermissionManager.updatePermission(
                                    origin,
                                    service.getComponentName().getPackageName(),
                                    ContentSettingsType.NOTIFICATIONS,
                                    ContentSettingValues.BLOCK);

                            // Attempting to notify when notifications are disabled won't have any
                            // effect, but returning here just saves us from doing unnecessary work.
                            return;
                        }

                        fallbackToIconFromServiceIfNecessary(builder, service);

                        NotificationMetadata metadata =
                                new NotificationMetadata(
                                        NotificationUmaTracker.SystemNotificationType
                                                .TRUSTED_WEB_ACTIVITY_SITES,
                                        platformTag,
                                        platformId);
                        Notification notification = builder.build(metadata).getNotification();

                        service.notify(platformTag, platformId, notification, channelDisplayName);

                        notificationUmaTracker.onNotificationShown(
                                NotificationUmaTracker.SystemNotificationType
                                        .TRUSTED_WEB_ACTIVITY_SITES,
                                notification);
                    }

                    @Override
                    public void onNoTwaFound() {
                        Log.w(TAG, "Unable to delegate notification.");
                    }
                });
    }

    private void fallbackToIconFromServiceIfNecessary(
            NotificationBuilderBase builder, Connection service) throws RemoteException {
        if (builder.hasSmallIconForContent() && builder.hasStatusBarIconBitmap()) {
            recordFallback(DelegatedNotificationSmallIconFallback.NO_FALLBACK);
            return;
        }

        int id = service.getSmallIconId();
        if (id == TrustedWebActivityService.SMALL_ICON_NOT_SET) {
            recordFallback(DelegatedNotificationSmallIconFallback.FALLBACK_ICON_NOT_PROVIDED);
            return;
        }

        recordFallback(
                builder.hasSmallIconForContent()
                        ? DelegatedNotificationSmallIconFallback.FALLBACK_FOR_STATUS_BAR
                        : DelegatedNotificationSmallIconFallback
                                .FALLBACK_FOR_STATUS_BAR_AND_CONTENT);

        Bitmap bitmap = service.getSmallIconBitmap();
        if (!builder.hasStatusBarIconBitmap()) {
            builder.setStatusBarIconForRemoteApp(id, bitmap);
        }
        if (!builder.hasSmallIconForContent()) {
            builder.setContentSmallIconForRemoteApp(bitmap);
        }
    }

    private void recordFallback(@DelegatedNotificationSmallIconFallback int fallback) {
        mRecorder.recordDelegatedNotificationSmallIconFallback(fallback);
    }

    /**
     * Cancels a notification through a Trusted Web Activity client.
     * @param scope The scope of the Service Worker that triggered the notification.
     * @param platformTag The tag of the notification to cancel.
     * @param platformId The id of the notification to cancel.
     */
    public void cancelNotification(Uri scope, String platformTag, int platformId) {
        connectAndExecute(
                scope,
                new ExecutionCallback() {
                    @Override
                    public void onConnected(Origin origin, Connection service)
                            throws RemoteException {
                        service.cancel(platformTag, platformId);
                    }

                    @Override
                    public void onNoTwaFound() {
                        Log.w(TAG, "Unable to cancel notification.");
                    }
                });
    }

    public void connectAndExecute(Uri scope, ExecutionCallback callback) {
        Origin origin = Origin.create(scope);
        if (origin == null) {
            callback.onNoTwaFound();
            return;
        }

        Set<Token> possiblePackages = mPermissionManager.getAllDelegateApps(origin);
        if (possiblePackages == null || possiblePackages.isEmpty()) {
            callback.onNoTwaFound();
            return;
        }

        mConnectionPool.connectAndExecute(scope, origin, possiblePackages, callback);
    }

    /**
     * Searches through the given list of {@link ResolveInfo} for an Activity belonging to a package
     * that is verified for the given url. If such an Activity is found, an Intent to start that
     * Activity as a Trusted Web Activity is returned. Otherwise {@code null} is returned.
     *
     * If multiple {@link ResolveInfo}s in the list match this criteria, the first will be chosen.
     */
    public static @Nullable Intent createLaunchIntentForTwa(
            Context appContext, String url, List<ResolveInfo> resolveInfosForUrl) {
        // This is ugly, but the call site for this is static and called by native.
        TrustedWebActivityClient client =
                ChromeApplicationImpl.getComponent().resolveTrustedWebActivityClient();
        return client.createLaunchIntentForTwaInternal(appContext, url, resolveInfosForUrl);
    }

    private @Nullable Intent createLaunchIntentForTwaInternal(
            Context appContext, String url, List<ResolveInfo> resolveInfosForUrl) {
        Origin origin = Origin.create(url);
        if (origin == null) return null;

        // Trusted Web Activities only work with https so we can shortcut here.
        if (!UrlConstants.HTTPS_SCHEME.equals(origin.uri().getScheme())) return null;

        ComponentName componentName =
                searchVerifiedApps(
                        appContext.getPackageManager(),
                        mPermissionManager.getAllDelegateApps(origin),
                        resolveInfosForUrl);

        if (componentName == null) return null;

        Intent intent = new Intent();
        intent.setData(Uri.parse(url));
        intent.setAction(Intent.ACTION_VIEW);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        intent.setComponent(componentName);
        return intent;
    }

    private static @Nullable ComponentName searchVerifiedApps(
            @NonNull PackageManager pm,
            @Nullable Set<Token> verifiedPackages,
            @NonNull List<ResolveInfo> resolveInfosForUrl) {
        if (verifiedPackages == null || verifiedPackages.isEmpty()) return null;

        for (ResolveInfo info : resolveInfosForUrl) {
            if (info.activityInfo == null) continue;

            for (Token v : verifiedPackages) {
                if (!v.matches(info.activityInfo.packageName, pm)) continue;

                return new ComponentName(info.activityInfo.packageName, info.activityInfo.name);
            }
        }

        return null;
    }

    private void notifyLocationUpdateError(TrustedWebActivityCallback callback, String message) {
        Bundle error = new Bundle();
        error.putString("message", message);
        callback.onExtraCallback(EXTRA_NEW_LOCATION_ERROR_CALLBACK, error);
    }

    private @Nullable Bundle safeSendExtraCommand(
            Connection service,
            String commandName,
            Bundle args,
            TrustedWebActivityCallback callback) {
        try {
            return service.sendExtraCommand(commandName, args, callback);
        } catch (Exception e) {
            // Catching all exceptions is really bad, but we need it here,
            // because Android exposes us to client bugs by throwing a variety
            // of exceptions. See crbug.com/1426591.
            Log.e(TAG, "There was an error with the client implementation", e);
            return null;
        }
    }
}