chromium/chrome/android/java/src/org/chromium/chrome/browser/webapps/WebApkServiceClient.java

// Copyright 2017 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.webapps;

import android.app.Activity;
import android.app.ActivityOptions;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.os.Build;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper;
import android.os.Messenger;
import android.os.RemoteException;

import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.Callback;
import org.chromium.base.ContextUtils;
import org.chromium.base.Log;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.browserservices.intents.WebApkExtras;
import org.chromium.chrome.browser.browserservices.metrics.WebApkUmaRecorder;
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.chrome.browser.notifications.channels.ChromeChannelDefinitions;
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.webapk.lib.client.WebApkServiceConnectionManager;
import org.chromium.webapk.lib.runtime_library.IWebApkApi;

/**
 * Provides APIs for browsers to communicate with WebAPK services. Each WebAPK has its own "WebAPK
 * service".
 */
public class WebApkServiceClient {
    // Callback which catches RemoteExceptions thrown due to IWebApkApi failure.
    @FunctionalInterface
    private interface ApiUseCallback extends WebApkServiceConnectionManager.ConnectionCallback {
        void useApi(IWebApkApi api) throws RemoteException;

        @Override
        default void onConnected(IBinder api) {
            if (api == null) {
                return;
            }

            try {
                useApi(IWebApkApi.Stub.asInterface(api));
            } catch (RemoteException e) {
                Log.w(TAG, "WebApkAPI use failed.", e);
            }
        }
    }

    @VisibleForTesting
    public static final String CATEGORY_WEBAPK_API = "android.intent.category.WEBAPK_API";

    private static final String TAG = "WebApkServiceClient";

    // An intent extra for a {@link Messenger}.
    private static final String EXTRA_MESSENGER = "messenger";

    // A bundle key for a {@link PermissionStatus}.
    private static final String KEY_PERMISSION_STATUS = "permissionStatus";

    private static WebApkServiceClient sInstance;

    /** Manages connections between the browser application and WebAPK services. */
    private WebApkServiceConnectionManager mConnectionManager;

    public static WebApkServiceClient getInstance() {
        if (sInstance == null) {
            sInstance = new WebApkServiceClient();
        }
        return sInstance;
    }

    private WebApkServiceClient() {
        mConnectionManager =
                new WebApkServiceConnectionManager(
                        TaskTraits.UI_DEFAULT, CATEGORY_WEBAPK_API, /* action= */ null);
    }

    /**
     * Gets the notification permission setting for the package.
     * @param permissionCallback To be called on a background thread with a permission setting, one
     *         of {@link ContentSettingValues}.
     */
    public void checkNotificationPermission(
            String webApkPackage, Callback<Integer> permissionCallback) {
        connect(
                webApkPackage,
                api -> {
                    @ContentSettingValues
                    int settingValue = toContentSettingValue(api.checkNotificationPermission());
                    permissionCallback.onResult(settingValue);
                });
    }

    private static Handler createPermissionHandler(Callback<Integer> permissionCallback) {
        return new Handler(
                Looper.getMainLooper(),
                message -> {
                    @ContentSettingValues
                    int settingValue =
                            toContentSettingValue(
                                    message.getData()
                                            .getInt(KEY_PERMISSION_STATUS, PermissionStatus.BLOCK));
                    permissionCallback.onResult(settingValue);
                    return true;
                });
    }

    /**
     * Requests the notification permission for the package.
     * @param permissionCallback To be called on a background thread with a permission setting, one
     *         of {@link ContentSettingValues}.
     */
    public void requestNotificationPermission(
            String webApkPackage, Callback<Integer> permissionCallback) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
            Log.w(TAG, "Requesting notification permission is not supported before T.");
            return;
        }
        connect(
                webApkPackage,
                api -> {
                    String channelName =
                            ContextUtils.getApplicationContext()
                                    .getString(R.string.webapk_notification_channel_name);

                    PendingIntent permissionRequestIntent =
                            api.requestNotificationPermission(
                                    channelName, ChromeChannelDefinitions.CHANNEL_ID_WEBAPKS);
                    if (permissionRequestIntent == null) {
                        permissionCallback.onResult(ContentSettingValues.ASK);
                        return;
                    }

                    Handler handler = createPermissionHandler(permissionCallback);
                    Intent extraIntent = new Intent();
                    extraIntent.putExtra(EXTRA_MESSENGER, new Messenger(handler));
                    try {
                        ActivityOptions options = ActivityOptions.makeBasic();
                        ApiCompatibilityUtils.setActivityOptionsBackgroundActivityStartMode(
                                options);
                        permissionRequestIntent.send(
                                ContextUtils.getApplicationContext(),
                                0,
                                extraIntent,
                                null,
                                null,
                                null,
                                options.toBundle());
                    } catch (PendingIntent.CanceledException e) {
                        Log.e(TAG, "The PendingIntent was canceled.", e);
                    }
                });
    }

    /**
     * Connects to a WebAPK's bound service, builds a notification and hands it over to the WebAPK
     * to display. Handing over the notification makes the notification look like it originated from
     * the WebAPK - not Chrome - in the Android UI.
     */
    public void notifyNotification(
            final String originString,
            final String webApkPackage,
            final NotificationBuilderBase notificationBuilder,
            final String platformTag,
            final int platformID) {
        connect(
                webApkPackage,
                api -> {
                    fallbackToWebApkIconIfNecessary(
                            notificationBuilder, webApkPackage, api.getSmallIconId());

                    @ContentSettingValues
                    int settingValue = toContentSettingValue(api.checkNotificationPermission());

                    // See http://crbug.com/1340854. Temporary fallback in case the shell has not
                    // yet been updated to support checkNotificationPermission(). Delete this
                    // after shell v154 has been fully launched.
                    if (settingValue != ContentSettingValues.ALLOW
                            && api.notificationPermissionEnabled()) {
                        Log.d(TAG, "Fallback to notificationPermissionEnabled().");
                        settingValue = ContentSettingValues.ALLOW;
                    }

                    WebApkUmaRecorder.recordNotificationPermissionStatus(settingValue);

                    if (settingValue != ContentSettingValues.ALLOW) {
                        Origin origin = Origin.create(originString);
                        if (origin == null) {
                            Log.w(TAG, "String (%s) could not be parsed as Origin.", originString);
                            return;
                        }
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                            InstalledWebappPermissionManager.get()
                                    .updatePermission(
                                            origin,
                                            webApkPackage,
                                            ContentSettingsType.NOTIFICATIONS,
                                            settingValue);
                        }
                        return;
                    }

                    String channelName = null;
                    if (webApkTargetsAtLeastO(webApkPackage)) {
                        notificationBuilder.setChannelId(
                                ChromeChannelDefinitions.CHANNEL_ID_WEBAPKS);
                        channelName =
                                ContextUtils.getApplicationContext()
                                        .getString(R.string.webapk_notification_channel_name);
                    }
                    NotificationMetadata metadata =
                            new NotificationMetadata(
                                    NotificationUmaTracker.SystemNotificationType.WEBAPK,
                                    platformTag,
                                    platformID);

                    api.notifyNotificationWithChannel(
                            platformTag,
                            platformID,
                            notificationBuilder.build(metadata).getNotification(),
                            channelName);
                });
    }

    private void fallbackToWebApkIconIfNecessary(
            NotificationBuilderBase builder, String webApkPackage, int iconId) {
        Bitmap icon = decodeImageResourceFromPackage(webApkPackage, iconId);
        if (!builder.hasSmallIconForContent()) {
            builder.setContentSmallIconForRemoteApp(icon);
        }
        if (!builder.hasStatusBarIconBitmap()) {
            builder.setStatusBarIconForRemoteApp(iconId, icon);
        }
    }

    /** Cancels notification previously shown by WebAPK. */
    public void cancelNotification(
            String webApkPackage, final String platformTag, final int platformID) {
        connect(webApkPackage, api -> api.cancelNotification(platformTag, platformID));
    }

    /** Finishes and removes the WebAPK's task. */
    public void finishAndRemoveTaskSdk23(final Activity activity, WebApkExtras webApkExtras) {
        connect(
                webApkExtras.webApkPackageName,
                api -> {
                    if (activity.isFinishing() || activity.isDestroyed()) return;

                    if (!api.finishAndRemoveTaskSdk23()) {
                        // If |activity| is not the root of the task, hopefully the activities below
                        // this one will close themselves.
                        activity.finish();
                    }
                });
    }

    /** Returns whether there are any WebAPK service API calls in progress. */
    public static boolean hasPendingWork() {
        return sInstance != null && !sInstance.mConnectionManager.didAllConnectCallbacksRun();
    }

    /** Disconnects all the connections to WebAPK services. */
    public static void disconnectAll() {
        if (sInstance == null) return;

        sInstance.mConnectionManager.disconnectAll(ContextUtils.getApplicationContext());
    }

    /** Returns whether the WebAPK targets SDK 26+. */
    private boolean webApkTargetsAtLeastO(String webApkPackage) {
        try {
            ApplicationInfo info =
                    ContextUtils.getApplicationContext()
                            .getPackageManager()
                            .getApplicationInfo(webApkPackage, 0);
            return info.targetSdkVersion >= Build.VERSION_CODES.O;
        } catch (PackageManager.NameNotFoundException e) {
        }

        return false;
    }

    /** Decodes into a Bitmap an Image resource stored in an APK with the given package name. */
    private static @Nullable Bitmap decodeImageResourceFromPackage(
            String packageName, int resourceId) {
        PackageManager packageManager = ContextUtils.getApplicationContext().getPackageManager();
        try {
            Resources resources = packageManager.getResourcesForApplication(packageName);
            return BitmapFactory.decodeResource(resources, resourceId);
        } catch (PackageManager.NameNotFoundException e) {
            return null;
        }
    }

    private void connect(String webApkPackage, ApiUseCallback connectionCallback) {
        mConnectionManager.connect(
                ContextUtils.getApplicationContext(), webApkPackage, connectionCallback);
    }

    private static @ContentSettingValues int toContentSettingValue(
            @PermissionStatus int permissionStatus) {
        if (permissionStatus == PermissionStatus.ALLOW) {
            return ContentSettingValues.ALLOW;
        }

        if (permissionStatus == PermissionStatus.ASK) {
            return ContentSettingValues.ASK;
        }

        return ContentSettingValues.BLOCK;
    }
}