chromium/chrome/android/java/src/org/chromium/chrome/browser/customtabs/features/minimizedcustomtab/CustomTabMinimizationManager.java

// Copyright 2023 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.customtabs.features.minimizedcustomtab;

import static org.chromium.chrome.browser.crash.ChromePureJavaExceptionReporter.reportJavaException;
import static org.chromium.chrome.browser.customtabs.features.minimizedcustomtab.MinimizedCardProperties.ALL_KEYS;
import static org.chromium.chrome.browser.customtabs.features.minimizedcustomtab.MinimizedCardProperties.FAVICON;
import static org.chromium.chrome.browser.customtabs.features.minimizedcustomtab.MinimizedCardProperties.TITLE;
import static org.chromium.chrome.browser.customtabs.features.minimizedcustomtab.MinimizedCardProperties.URL;
import static org.chromium.chrome.browser.tab.TabLoadIfNeededCaller.ON_ACTIVITY_SHOWN_THEN_SHOW;
import static org.chromium.chrome.browser.tab.TabSelectionType.FROM_USER;

import android.app.PictureInPictureParams;
import android.os.Build.VERSION;
import android.os.Build.VERSION_CODES;
import android.os.Bundle;
import android.os.SystemClock;
import android.util.Rational;

import androidx.annotation.IntDef;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.PictureInPictureModeChangedInfo;
import androidx.core.util.Consumer;
import androidx.lifecycle.Lifecycle.State;

import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.base.supplier.Supplier;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.ActivityTabProvider;
import org.chromium.chrome.browser.browserservices.intents.BrowserServicesIntentDataProvider;
import org.chromium.chrome.browser.customtabs.CustomTabsConnection;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.InflationObserver;
import org.chromium.chrome.browser.lifecycle.SaveInstanceStateObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabFavicon;
import org.chromium.chrome.browser.tab.TabHidingType;
import org.chromium.components.dom_distiller.core.DomDistillerUrlUtils;
import org.chromium.components.url_formatter.SchemeDisplay;
import org.chromium.components.url_formatter.UrlFormatter;
import org.chromium.content_public.common.ContentUrlConstants;
import org.chromium.ui.modelutil.PropertyModel;
import org.chromium.ui.widget.Toast;
import org.chromium.url.GURL;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.ref.WeakReference;
import java.util.concurrent.TimeUnit;

/** Class that manages minimizing a Custom Tab into picture-in-picture. */
public class CustomTabMinimizationManager
        implements CustomTabMinimizeDelegate,
                Consumer<PictureInPictureModeChangedInfo>,
                SaveInstanceStateObserver {
    // List of possible minimization events - maximize is effectively an 'un-PiP', whereas destroy
    // refers to the activity being finished either by user action or otherwise.
    // These values are persisted to logs. Entries should not be renumbered and
    // numeric values should never be reused.
    @IntDef({
        MinimizationEvents.MINIMIZE,
        MinimizationEvents.MAXIMIZE,
        MinimizationEvents.DESTROY,
        MinimizationEvents.COUNT
    })
    @Retention(RetentionPolicy.SOURCE)
    @interface MinimizationEvents {
        int MINIMIZE = 0;
        int MAXIMIZE = 1;
        int DESTROY = 2;

        int COUNT = 3;
    }

    @VisibleForTesting static final Rational ASPECT_RATIO = new Rational(16, 9);

    @VisibleForTesting static WeakReference<CustomTabMinimizeDelegate> sLastMinimizeDelegate;

    @VisibleForTesting static final String KEY_IS_CCT_MINIMIZED = "isCctMinimized";

    @VisibleForTesting
    static final String KEY_CCT_MINIMIZATION_SYSTEM_TIME = "cctMinimizationSystemTime";

    // --- For debugging
    private static final String TAG = "CTMinimizationMgr";
    private static final String TASK_DISPLAY_AREA_NPE_STR =
            "com.android.server.wm.TaskDisplayArea.positionStackAtTop";
    private static final String DEVICE_DOES_NOT_SUPPORT_ISE_STR =
            "Device doesn't support picture-in-picture mode";
    private static final String ACTIVITY_DOES_NOT_SUPPORT_ISE_STR =
            "Current activity does not support picture-in-picture";
    private static final String ROOT_TASK_IAE_STR = "addRootTaskReferenceIfNeeded: root pinned";
    // ---

    private final AppCompatActivity mActivity;
    private final ActivityTabProvider mTabProvider;
    private final MinimizedCustomTabFeatureEngagementDelegate mFeatureEngagementDelegate;
    private final BrowserServicesIntentDataProvider mIntentData;
    private final Runnable mCloseTabRunnable;
    private final ObserverList<Observer> mObservers = new ObserverList<>();
    private final ActivityLifecycleDispatcher mLifecycleDispatcher;
    private final Supplier<Bundle> mSavedInstanceStateSupplier;
    private MinimizedCardCoordinator mCoordinator;
    private PropertyModel mModel;
    private long mMinimizationSystemTime;
    private boolean mMinimized;

    /**
     * @param activity The {@link AppCompatActivity} to minimize.
     * @param tabProvider The {@link ActivityTabProvider} that provides the Tab that will be
     *     minimized.
     * @param featureEngagementDelegate The {@link MinimizedCustomTabFeatureEngagementDelegate}.
     * @param closeTabRunnable The {@link Runnable} to close the Custom Tab when the minimized tab
     *     is dismissed.
     * @param intentData The {@link BrowserServicesIntentDataProvider}.
     * @param lifecycleDispatcher The {@link ActivityLifecycleDispatcher}.
     * @param savedInstanceStateSupplier {@link Supplier} for the saved instance state.
     */
    public CustomTabMinimizationManager(
            AppCompatActivity activity,
            ActivityTabProvider tabProvider,
            MinimizedCustomTabFeatureEngagementDelegate featureEngagementDelegate,
            Runnable closeTabRunnable,
            BrowserServicesIntentDataProvider intentData,
            ActivityLifecycleDispatcher lifecycleDispatcher,
            Supplier<Bundle> savedInstanceStateSupplier) {
        mActivity = activity;
        mTabProvider = tabProvider;
        mFeatureEngagementDelegate = featureEngagementDelegate;
        mCloseTabRunnable = closeTabRunnable;
        mIntentData = intentData;
        mLifecycleDispatcher = lifecycleDispatcher;
        mSavedInstanceStateSupplier = savedInstanceStateSupplier;

        mLifecycleDispatcher.register(this);

        maybeInitializeAsMinimized();
    }

    public void destroy() {
        mActivity.removeOnPictureInPictureModeChangedListener(this);
    }

    @Override
    public void onSaveInstanceState(Bundle outState) {
        if (mMinimized) {
            outState.putBoolean(KEY_IS_CCT_MINIMIZED, true);
            outState.putLong(KEY_CCT_MINIMIZATION_SYSTEM_TIME, mMinimizationSystemTime);
            putIntoBundleFromModel(outState, mModel);
        }
    }

    /** Minimize the Custom Tab into picture-in-picture. */
    @Override
    public void minimize() {
        if (mMinimized) return;
        if (!mTabProvider.hasValue()) return;
        mFeatureEngagementDelegate.notifyUserEngaged();
        var builder = new PictureInPictureParams.Builder().setAspectRatio(ASPECT_RATIO);
        if (VERSION.SDK_INT >= VERSION_CODES.S) {
            builder.setSeamlessResizeEnabled(false);
        }

        maybeDismissLastMinimizedTab();

        // Sometimes an exception may be thrown by the framework code. If it's something we've seen
        // before, catch it and report with extra debug information without crashing. Otherwise,
        // rethrow so we don't inadvertently hide other crashes.
        try {
            mMinimized = mActivity.enterPictureInPictureMode(builder.build());
            recordMinimizeSuccess(/* success= */ true);
        } catch (NullPointerException e) {
            if (doesExceptionMatch(e, TASK_DISPLAY_AREA_NPE_STR)) {
                String msg = "NullPointerException";
                reportException(TASK_DISPLAY_AREA_NPE_STR, msg, e);
            } else {
                throw e;
            }
        } catch (IllegalStateException e) {
            if (doesExceptionMatch(e, DEVICE_DOES_NOT_SUPPORT_ISE_STR)) {
                String msg = "Device doesn't support picture-in-picture mode.";
                reportException(DEVICE_DOES_NOT_SUPPORT_ISE_STR, msg, e);
            } else if (doesExceptionMatch(e, ACTIVITY_DOES_NOT_SUPPORT_ISE_STR)) {
                String msg =
                        "Current activity does not support picture-in-picture. Activity class: "
                                + mActivity.getLocalClassName();
                reportException(ACTIVITY_DOES_NOT_SUPPORT_ISE_STR, msg, e);
            } else {
                throw e;
            }
        } catch (IllegalArgumentException e) {
            if (doesExceptionMatch(e, ROOT_TASK_IAE_STR)) {
                String msg = "IllegalArgumentException";
                reportException(ROOT_TASK_IAE_STR, msg, e);
            } else {
                throw e;
            }
        }
        if (!mMinimized) {
            recordMinimizeSuccess(/* success= */ false);
            Toast.makeText(mActivity, R.string.minimize_failure_toast, Toast.LENGTH_SHORT).show();
            return;
        }

        maybeSaveLastMinimizeDelegate();

        mActivity.addOnPictureInPictureModeChangedListener(this);
        notifyObservers(true);
        mMinimizationSystemTime = SystemClock.elapsedRealtime();
    }

    @Override
    public void dismiss() {
        mCloseTabRunnable.run();
    }

    @Override
    public boolean isMinimized() {
        return mMinimized;
    }

    @Override
    public void addObserver(Observer observer) {
        mObservers.addObserver(observer);
    }

    @Override
    public void removeObserver(Observer observer) {
        mObservers.removeObserver(observer);
    }

    @Override
    public void accept(PictureInPictureModeChangedInfo pictureInPictureModeChangedInfo) {
        if (!mMinimized) return;

        Tab tab = mTabProvider.get();

        if (tab == null) {
            boolean wasInitializedMinimized =
                    mSavedInstanceStateSupplier.hasValue()
                            && mSavedInstanceStateSupplier.get().getBoolean(KEY_IS_CCT_MINIMIZED);
            String msg =
                    "Tab is null. Activity state is "
                            + mActivity.getLifecycle().getCurrentState()
                            + ". wasInitializedMinimized: "
                            + wasInitializedMinimized
                            + ". isInPip: "
                            + pictureInPictureModeChangedInfo.isInPictureInPictureMode();
            Log.e(TAG, msg);
            reportJavaException(new Exception(msg));
        }

        if (pictureInPictureModeChangedInfo.isInPictureInPictureMode()) {
            showMinimizedCard(/* fromSavedState= */ false);
            updateTabForMinimization(tab);
            CustomTabsConnection.getInstance().onMinimized(mIntentData.getSession());
            RecordHistogram.recordEnumeratedHistogram(
                    "CustomTabs.MinimizedEvents",
                    MinimizationEvents.MINIMIZE,
                    MinimizationEvents.COUNT);
        } else {
            mActivity.removeOnPictureInPictureModeChangedListener(this);
            notifyObservers(false);
            maybeClearLastMinimizedTabRef();
            // We receive an update here when PiP is dismissed and the Activity is being stopped
            // before destruction. In that case, the state will be CREATED.
            var state = mActivity.getLifecycle().getCurrentState();
            if (state == State.CREATED || state == State.DESTROYED) {
                RecordHistogram.recordEnumeratedHistogram(
                        "CustomTabs.MinimizedEvents",
                        MinimizationEvents.DESTROY,
                        MinimizationEvents.COUNT);
                if (mMinimizationSystemTime != 0) {
                    RecordHistogram.recordTimesHistogram(
                            "CustomTabs.TimeElapsedSinceMinimized.Destroyed",
                            TimeUnit.MILLISECONDS.toSeconds(
                                    SystemClock.elapsedRealtime() - mMinimizationSystemTime));
                }
                mCloseTabRunnable.run();
                return;
            }

            mMinimized = false;
            updateTabForMaximization(tab);
            CustomTabsConnection.getInstance().onUnminimized(mIntentData.getSession());
            RecordHistogram.recordEnumeratedHistogram(
                    "CustomTabs.MinimizedEvents",
                    MinimizationEvents.MAXIMIZE,
                    MinimizationEvents.COUNT);
            if (mMinimizationSystemTime != 0) {
                RecordHistogram.recordTimesHistogram(
                        "CustomTabs.TimeElapsedSinceMinimized.Maximized",
                        TimeUnit.MILLISECONDS.toSeconds(
                                SystemClock.elapsedRealtime() - mMinimizationSystemTime));
            }
        }
    }

    private void maybeInitializeAsMinimized() {
        mMinimized =
                mSavedInstanceStateSupplier.hasValue()
                        && mSavedInstanceStateSupplier.get().getBoolean(KEY_IS_CCT_MINIMIZED);

        if (mMinimized) {
            mLifecycleDispatcher.register(
                    new InflationObserver() {
                        @Override
                        public void onPreInflationStartup() {}

                        @Override
                        public void onPostInflationStartup() {
                            maybeSaveLastMinimizeDelegate();
                            mActivity.addOnPictureInPictureModeChangedListener(
                                    CustomTabMinimizationManager.this);
                            showMinimizedCard(/* fromSavedState= */ true);
                            notifyObservers(true);
                            mMinimizationSystemTime =
                                    mSavedInstanceStateSupplier
                                            .get()
                                            .getLong(KEY_CCT_MINIMIZATION_SYSTEM_TIME);
                            mLifecycleDispatcher.unregister(this);
                        }
                    });
        }
    }

    private void showMinimizedCard(boolean fromSavedState) {
        if (fromSavedState) {
            assert mSavedInstanceStateSupplier.hasValue();
            mModel = toModel(mSavedInstanceStateSupplier.get());
        } else {
            Tab tab = mTabProvider.get();
            if (tab == null) return;
            GURL url =
                    DomDistillerUrlUtils.isDistilledPage(tab.getUrl())
                            ? tab.getOriginalUrl()
                            : tab.getUrl();
            String host =
                    UrlFormatter.formatUrlForSecurityDisplay(url, SchemeDisplay.OMIT_CRYPTOGRAPHIC);
            String title =
                    ContentUrlConstants.ABOUT_BLANK_DISPLAY_URL.equals(host) ? "" : tab.getTitle();
            mModel =
                    new PropertyModel.Builder(ALL_KEYS)
                            .with(TITLE, title)
                            .with(URL, host)
                            .with(FAVICON, TabFavicon.getBitmap(tab))
                            .build();
        }
        mCoordinator =
                new MinimizedCardCoordinator(
                        mActivity, mActivity.findViewById(android.R.id.content), mModel);
    }

    private void updateTabForMinimization(Tab tab) {
        if (tab == null) return;

        tab.stopLoading();
        tab.hide(TabHidingType.ACTIVITY_HIDDEN);
        var webContents = tab.getWebContents();
        if (webContents != null) {
            webContents.suspendAllMediaPlayers();
            webContents.setAudioMuted(true);
        }
    }

    private void updateTabForMaximization(Tab tab) {
        if (tab == null) return;
        tab.show(FROM_USER, ON_ACTIVITY_SHOWN_THEN_SHOW);
        var webContents = tab.getWebContents();
        if (webContents != null) {
            webContents.setAudioMuted(false);
        }
        if (mCoordinator != null) {
            mCoordinator.dismiss();
        }
    }

    private void notifyObservers(boolean minimized) {
        for (var obs : mObservers) {
            obs.onMinimizationChanged(minimized);
        }
    }

    private CustomTabMinimizeDelegate getLastMinimizeDelegate() {
        if (sLastMinimizeDelegate == null) return null;

        return sLastMinimizeDelegate.get();
    }

    private void maybeSaveLastMinimizeDelegate() {
        if (VERSION.SDK_INT < VERSION_CODES.R || VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) return;

        sLastMinimizeDelegate = new WeakReference<>(this);
    }

    private void clearLastMinimizeDelegate() {
        if (sLastMinimizeDelegate == null) return;

        sLastMinimizeDelegate.clear();
        sLastMinimizeDelegate = null;
    }

    private void maybeDismissLastMinimizedTab() {
        // On Android R and S, minimizing a tab while there is already an active PiP window unPiPs
        // the current one instead of closing the Activity. This can cause some weird issues if it
        // happens multiple times back-to-back. To prevent these issues, we dismiss the last
        // minimized Custom Tab. This only works for the Minimized Custom Tabs feature and doesn't
        // affect other uses of PiP such as fullscreen video.
        if (VERSION.SDK_INT < VERSION_CODES.R || VERSION.SDK_INT >= VERSION_CODES.TIRAMISU) return;

        var lastMinimized = getLastMinimizeDelegate();
        if (lastMinimized != null) {
            lastMinimized.dismiss();
            clearLastMinimizeDelegate();
        }
    }

    private void maybeClearLastMinimizedTabRef() {
        var lastMinimized = getLastMinimizeDelegate();
        if (lastMinimized == this) {
            clearLastMinimizeDelegate();
        }
    }

    private static void putIntoBundleFromModel(Bundle out, PropertyModel model) {
        if (model == null) return;

        out.putString(TITLE.toString(), model.get(TITLE));
        out.putString(URL.toString(), model.get(URL));
        out.putParcelable(FAVICON.toString(), model.get(FAVICON));
    }

    private static PropertyModel toModel(Bundle bundle) {
        return new PropertyModel.Builder(ALL_KEYS)
                .with(TITLE, bundle.getString(TITLE.toString()))
                .with(URL, bundle.getString(URL.toString()))
                .with(FAVICON, bundle.getParcelable(FAVICON.toString()))
                .build();
    }

    private boolean doesExceptionMatch(Exception e, String subString) {
        return e.getMessage() != null && e.getMessage().contains(subString);
    }

    private void reportException(String key, String msg, Exception e) {
        String msgWithState =
                msg + " -- ActivityState: " + mLifecycleDispatcher.getCurrentActivityState();
        Log.e(TAG, msgWithState, e);
        reportJavaException(new Exception(msg, e));
    }

    private void recordMinimizeSuccess(boolean success) {
        RecordHistogram.recordBooleanHistogram("CustomTabs.Minimized.MinimizeSuccess", success);
    }
}