chromium/chrome/browser/paint_preview/android/java/src/org/chromium/chrome/browser/paint_preview/TabbedPaintPreview.java

// Copyright 2020 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.paint_preview;

import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.app.Activity;
import android.content.Context;
import android.graphics.Point;
import android.os.Handler;
import android.os.SystemClock;
import android.view.View;

import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;

import org.chromium.base.Callback;
import org.chromium.base.TraceEvent;
import org.chromium.base.UserData;
import org.chromium.chrome.browser.browser_controls.BrowserStateBrowserControlsVisibilityDelegate;
import org.chromium.chrome.browser.paint_preview.services.PaintPreviewTabService;
import org.chromium.chrome.browser.paint_preview.services.PaintPreviewTabServiceFactory;
import org.chromium.chrome.browser.tab.EmptyTabObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabHidingType;
import org.chromium.chrome.browser.tab.TabObserver;
import org.chromium.chrome.browser.tab.TabViewProvider;
import org.chromium.components.browser_ui.styles.ChromeColors;
import org.chromium.components.paintpreview.player.PlayerManager;
import org.chromium.content_public.browser.RenderCoordinates;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsAccessibility;
import org.chromium.ui.base.EventForwarder;
import org.chromium.ui.base.GestureEventType;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.ui.util.TokenHolder;

/**
 * Responsible for checking for and displaying Paint Previews that are associated with a
 * {@link Tab} by overlaying the content view.
 */
public class TabbedPaintPreview implements UserData {
    public static final Class<TabbedPaintPreview> USER_DATA_KEY = TabbedPaintPreview.class;
    private static final int CROSS_FADE_DURATION_MS = 500;
    private static final int SCROLL_DELAY_MS = 10;

    private Tab mTab;
    private TabObserver mTabObserver;
    private TabViewProvider mTabbedPaintPreviewViewProvider;
    private PaintPreviewTabService mPaintPreviewTabService;
    private PlayerManager mPlayerManager;
    private BrowserStateBrowserControlsVisibilityDelegate mBrowserVisibilityDelegate;
    private Runnable mProgressSimulatorNeededCallback;
    private Callback<Boolean> mProgressPreventionCallback;

    private boolean mIsAttachedToTab;
    private boolean mFadingOut;
    private int mPersistentToolbarToken = TokenHolder.INVALID_TOKEN;

    private static PaintPreviewTabService sPaintPreviewTabServiceForTesting;
    private boolean mWasEverShown;

    public static TabbedPaintPreview get(Tab tab) {
        if (tab.getUserDataHost().getUserData(USER_DATA_KEY) == null) {
            tab.getUserDataHost().setUserData(USER_DATA_KEY, new TabbedPaintPreview(tab));
        }
        return tab.getUserDataHost().getUserData(USER_DATA_KEY);
    }

    private TabbedPaintPreview(Tab tab) {
        mTab = tab;
        mTabbedPaintPreviewViewProvider = new TabbedPaintPreviewViewProvider();
        mPaintPreviewTabService = PaintPreviewTabServiceFactory.getServiceInstance();
        mTabObserver =
                new EmptyTabObserver() {
                    @Override
                    public void onHidden(Tab tab, @TabHidingType int hidingType) {
                        releasePersistentToolbar();
                        setProgressPreventionNeeded(false);
                    }

                    @Override
                    public void onShown(Tab tab, int type) {
                        if (!isShowing()) return;

                        showToolbarPersistent();
                        setProgressPreventionNeeded(true);
                    }

                    @Override
                    public void onActivityAttachmentChanged(
                            Tab tab, @Nullable WindowAndroid window) {
                        // Intentionally do nothing to prevent automatic observer removal on
                        // detachment.
                    }
                };
    }

    public void setBrowserVisibilityDelegate(
            BrowserStateBrowserControlsVisibilityDelegate browserVisibilityDelegate) {
        mBrowserVisibilityDelegate = browserVisibilityDelegate;
    }

    public void setProgressSimulatorNeededCallback(Runnable callback) {
        mProgressSimulatorNeededCallback = callback;
    }

    public void setProgressbarUpdatePreventionCallback(Callback<Boolean> callback) {
        mProgressPreventionCallback = callback;
    }

    void capture(Callback<Boolean> successCallback) {
        getService().captureTab(mTab, successCallback);
    }

    /**
     * Shows a Paint Preview for the provided tab if it exists.
     * @param listener An interface used for notifying events originated from the player.
     * @return Whether a capture for this tab exists and an attempt for displaying it has started.
     */
    public boolean maybeShow(@NonNull PlayerManager.Listener listener) {
        if (mIsAttachedToTab) return true;
        TraceEvent.begin("TabbedPaintPreview.maybeShow");

        boolean allowedToShow = PaintPreviewTabService.tabAllowedForPaintPreview(mTab);
        if (!allowedToShow) {
            TraceEvent.end("TabbedPaintPreview.maybeShow");
            return false;
        }

        // Check if a capture exists. This is a quick check using a cache.
        boolean hasCapture = getService().hasCaptureForTab(mTab.getId());
        if (!hasCapture) {
            TraceEvent.end("TabbedPaintPreview.maybeShow");
            return false;
        }

        mTab.addObserver(mTabObserver);
        PaintPreviewCompositorUtils.warmupCompositor();

        mPlayerManager =
                new PlayerManager(
                        mTab.getUrl(),
                        mTab.getContext(),
                        getService(),
                        String.valueOf(mTab.getId()),
                        listener,
                        ChromeColors.getPrimaryBackgroundColor(mTab.getContext(), false),
                        /* ignoreInitialScrollOffset= */ false);

        // TODO(crbug.com/40190158): Consider deferring/post tasking. Locally this appears to be
        // slow.
        TraceEvent.begin("TabbedPaintPreview.maybeShow addTabViewProvider");
        mTab.getTabViewManager().addTabViewProvider(mTabbedPaintPreviewViewProvider);
        TraceEvent.end("TabbedPaintPreview.maybeShow addTabViewProvider");
        mIsAttachedToTab = true;
        mWasEverShown = true;

        TraceEvent.end("TabbedPaintPreview.maybeShow");
        return true;
    }

    public void remove(boolean animate) {
        remove(true, animate);
    }

    private void matchScrollAndScale(
            WebContents contents, Point scrollPosition, float scaleFactor) {
        if (contents == null || scaleFactor == 0f || scrollPosition == null) return;
        EventForwarder eventForwarder = contents.getEventForwarder();
        RenderCoordinates coordinates = RenderCoordinates.fromWebContents(contents);

        float scaleDelta = scaleFactor / coordinates.getPageScaleFactor();
        long timeMs = SystemClock.uptimeMillis();
        eventForwarder.onGestureEvent(GestureEventType.PINCH_BEGIN, timeMs, 0.f);
        eventForwarder.onGestureEvent(GestureEventType.PINCH_BY, timeMs, scaleDelta);
        eventForwarder.onGestureEvent(GestureEventType.PINCH_END, timeMs, 0.f);
        // Post the scroll so it occurs after the scale. This ensures positioning is correct.
        new Handler()
                .postDelayed(
                        () -> {
                            eventForwarder.scrollTo(scrollPosition.x, scrollPosition.y);
                        },
                        SCROLL_DELAY_MS);
    }

    /**
     * Removes the view containing the Paint Preview from the most recently shown {@link Tab}. Does
     * nothing if there is no view showing.
     */
    public void remove(boolean matchScroll, boolean animate) {
        PaintPreviewCompositorUtils.stopWarmCompositor();
        if (mTab == null || mPlayerManager == null || mFadingOut) return;
        TraceEvent.begin("TabbedPaintPreview.remove");

        mFadingOut = true;
        mPlayerManager.setAcceptUserInput(false);
        mTab.removeObserver(mTabObserver);
        Point scrollPosition = mPlayerManager.getScrollPosition();
        float scale = mPlayerManager.getScale();
        final boolean supportsAccessibility = mPlayerManager.supportsAccessibility();
        // Destroy early to free up resource, but don't null until faded out so view sticks around.
        mPlayerManager.destroy();
        if (matchScroll) {
            matchScrollAndScale(mTab.getWebContents(), scrollPosition, scale);
        }
        mTabbedPaintPreviewViewProvider
                .getView()
                .animate()
                .alpha(0f)
                .setDuration(animate ? CROSS_FADE_DURATION_MS : 0)
                .setListener(
                        new AnimatorListenerAdapter() {
                            @Override
                            public void onAnimationEnd(Animator animation) {
                                if (mTab != null) {
                                    mTab.getTabViewManager()
                                            .removeTabViewProvider(mTabbedPaintPreviewViewProvider);
                                }
                                if (mPlayerManager != null) {
                                    mPlayerManager = null;
                                }
                                // WebContentsAccessibilityImpl gets its focus stuck on the root ID.
                                // Clear focus here to solve this problem.
                                if (supportsAccessibility) clearFocus();

                                mIsAttachedToTab = false;
                                mFadingOut = false;
                            }
                        });
        // Ensure the progress update occur during the animation.
        setProgressPreventionNeeded(false);

        if (mProgressSimulatorNeededCallback != null) mProgressSimulatorNeededCallback.run();
        TraceEvent.end("TabbedPaintPreview.remove");
    }

    /** Clears focus and accessibility focus. */
    private void clearFocus() {
        WebContents webContents = mTab != null ? mTab.getWebContents() : null;
        if (webContents == null || webContents.isDestroyed()) return;

        // Clear input focus. This is required due to a bug where the root view is treated as
        // focused for input on exit causing talkback to attempt to return focus to the root view.
        // TODO(crbug.com/40760302): this approach could cause loss of focus in a menu, omnibox,
        // etc.
        // is there a less heavy-handed option here?
        WindowAndroid window = webContents.getTopLevelNativeWindow();
        Activity activity = window != null ? window.getActivity().get() : null;
        View v = activity != null ? activity.getCurrentFocus() : null;
        if (v != null) v.clearFocus();

        // Clear accessibility focus.
        WebContentsAccessibility wcax = WebContentsAccessibility.fromWebContents(webContents);
        if (wcax != null) wcax.resetFocus();
    }

    public boolean isShowing() {
        if (mTab == null) return false;

        return mTab.getTabViewManager().isShowing(mTabbedPaintPreviewViewProvider);
    }

    public boolean isAttached() {
        return mIsAttachedToTab;
    }

    /** Persistently shows the toolbar and avoids hiding it on scrolling down. */
    private void showToolbarPersistent() {
        if (mBrowserVisibilityDelegate == null
                || mPersistentToolbarToken != TokenHolder.INVALID_TOKEN) {
            return;
        }

        mPersistentToolbarToken = mBrowserVisibilityDelegate.showControlsPersistent();
    }

    private void releasePersistentToolbar() {
        if (mBrowserVisibilityDelegate == null) return;

        mBrowserVisibilityDelegate.releasePersistentShowingToken(mPersistentToolbarToken);
        mPersistentToolbarToken = TokenHolder.INVALID_TOKEN;
    }

    /**
     * @param progressPrevention Whether progress updates shown in the progress bar should be
     *                           suppressed.
     */
    private void setProgressPreventionNeeded(boolean progressPrevention) {
        if (mProgressPreventionCallback == null) return;

        mProgressPreventionCallback.onResult(progressPrevention);
    }

    @Override
    public void destroy() {
        mTab.removeObserver(mTabObserver);
        mTab = null;
    }

    private PaintPreviewTabService getService() {
        if (sPaintPreviewTabServiceForTesting == null) return mPaintPreviewTabService;

        return sPaintPreviewTabServiceForTesting;
    }

    static void overridePaintPreviewTabServiceForTesting(PaintPreviewTabService service) {
        sPaintPreviewTabServiceForTesting = service;
    }

    @VisibleForTesting
    boolean wasEverShown() {
        return mWasEverShown;
    }

    View getViewForTesting() {
        return mTabbedPaintPreviewViewProvider.getView();
    }

    PlayerManager getPlayerManagerForTesting() {
        return mPlayerManager;
    }

    private class TabbedPaintPreviewViewProvider implements TabViewProvider {
        @Override
        public int getTabViewProviderType() {
            return Type.PAINT_PREVIEW;
        }

        @Override
        public View getView() {
            return mPlayerManager == null ? null : mPlayerManager.getView();
        }

        @Override
        public void onShown() {
            showToolbarPersistent();
            setProgressPreventionNeeded(true);
        }

        @Override
        public @ColorInt int getBackgroundColor(Context context) {
            // TODO(crbug.com/337883538): should be replaced by the background of the preview image
            // rather that the primary background color.
            return ChromeColors.getPrimaryBackgroundColor(mTab.getContext(), false);
        }

        @Override
        public void onHidden() {
            releasePersistentToolbar();
            setProgressPreventionNeeded(false);
        }
    }
}