chromium/chrome/android/java/src/org/chromium/chrome/browser/browserservices/ui/splashscreen/SplashController.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.browserservices.ui.splashscreen;

import android.app.Activity;
import android.graphics.PixelFormat;
import android.os.SystemClock;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewPropertyAnimator;
import android.view.ViewTreeObserver;

import androidx.annotation.Nullable;

import dagger.Lazy;

import org.chromium.base.ObserverList;
import org.chromium.base.TraceEvent;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.browserservices.trustedwebactivityui.TwaFinishHandler;
import org.chromium.chrome.browser.compositor.CompositorViewHolder;
import org.chromium.chrome.browser.customtabs.BaseCustomTabActivity;
import org.chromium.chrome.browser.customtabs.CustomTabOrientationController;
import org.chromium.chrome.browser.customtabs.content.CustomTabActivityTabProvider;
import org.chromium.chrome.browser.customtabs.content.TabCreationMode;
import org.chromium.chrome.browser.customtabs.content.TabObserverRegistrar;
import org.chromium.chrome.browser.customtabs.content.TabObserverRegistrar.CustomTabTabObserver;
import org.chromium.chrome.browser.dependency_injection.ActivityScope;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.DestroyObserver;
import org.chromium.chrome.browser.lifecycle.InflationObserver;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.url.GURL;

import java.lang.reflect.Method;

import javax.inject.Inject;

/** Shows and hides splash screen for Webapps, WebAPKs and TWAs. */
@ActivityScope
public class SplashController extends CustomTabTabObserver
        implements InflationObserver, DestroyObserver {
    private static class SingleShotOnDrawListener implements ViewTreeObserver.OnDrawListener {
        private final View mView;
        private final Runnable mAction;
        private boolean mHasRun;

        public static void install(View view, Runnable action) {
            view.getViewTreeObserver()
                    .addOnDrawListener(new SingleShotOnDrawListener(view, action));
        }

        private SingleShotOnDrawListener(View view, Runnable action) {
            mView = view;
            mAction = action;
        }

        @Override
        public void onDraw() {
            if (mHasRun) return;
            mHasRun = true;
            mAction.run();
            // Cannot call removeOnDrawListener within OnDraw, so do on next tick.
            mView.post(() -> mView.getViewTreeObserver().removeOnDrawListener(this));
        }
    }

    private final Activity mActivity;
    private final ActivityLifecycleDispatcher mLifecycleDispatcher;
    private final TabObserverRegistrar mTabObserverRegistrar;
    private final TwaFinishHandler mFinishHandler;
    private final CustomTabActivityTabProvider mTabProvider;
    private final Lazy<CompositorViewHolder> mCompositorViewHolder;

    private SplashDelegate mDelegate;

    /** View to which the splash screen is added. */
    private ViewGroup mParentView;

    @Nullable private View mSplashView;

    @Nullable private ViewPropertyAnimator mFadeOutAnimator;

    /** The duration of the splash hide animation. */
    private long mSplashHideAnimationDurationMs;

    private boolean mDidPreInflationStartup;

    /** Whether the splash hide animation was started. */
    private boolean mWasSplashHideAnimationStarted;

    /** Time that the splash screen was shown. */
    private long mSplashShownTimestamp;

    /** Indicates whether translucency should be removed. */
    private boolean mIsWindowInitiallyTranslucent;

    /** Whether translucency was removed. */
    private boolean mRemovedTranslucency;

    private ObserverList<SplashscreenObserver> mObservers;

    @Inject
    public SplashController(
            Activity activity,
            ActivityLifecycleDispatcher lifecycleDispatcher,
            TabObserverRegistrar tabObserverRegistrar,
            CustomTabOrientationController orientationController,
            TwaFinishHandler finishHandler,
            CustomTabActivityTabProvider tabProvider,
            Lazy<CompositorViewHolder> compositorViewHolder) {
        mActivity = activity;
        mLifecycleDispatcher = lifecycleDispatcher;
        mTabObserverRegistrar = tabObserverRegistrar;
        mObservers = new ObserverList<>();
        mFinishHandler = finishHandler;
        mTabProvider = tabProvider;
        mCompositorViewHolder = compositorViewHolder;

        mIsWindowInitiallyTranslucent =
                BaseCustomTabActivity.isWindowInitiallyTranslucent(activity);

        orientationController.delayOrientationRequestsIfNeeded(this, mIsWindowInitiallyTranslucent);

        mLifecycleDispatcher.register(this);
        mTabObserverRegistrar.registerActivityTabObserver(this);
    }

    public void setConfig(SplashDelegate delegate, long splashHideAnimationDurationMs) {
        mDelegate = delegate;
        mSplashHideAnimationDurationMs = splashHideAnimationDurationMs;
        if (mDidPreInflationStartup) {
            showSplash();
        }
    }

    /**
     * Brings splash view back to the front of the parent's view hierarchy, reattaching the view to
     * the parent if necessary.
     */
    public void bringSplashBackToFront() {
        if (mSplashView == null) return;

        if (mSplashView.getParent() != null) {
            mParentView.removeView(mSplashView);
        }
        mParentView.addView(mSplashView);
    }

    public View getSplashScreenForTests() {
        return mSplashView;
    }

    public boolean wasSplashScreenHiddenForTests() {
        return mSplashShownTimestamp > 0 && mSplashView == null;
    }

    @Override
    public void onPreInflationStartup() {
        mDidPreInflationStartup = true;
        if (mDelegate != null) {
            showSplash();
        }
    }

    @Override
    public void onPostInflationStartup() {
        bringSplashBackToFront();
    }

    @Override
    public void onDestroy() {
        if (mFadeOutAnimator != null) {
            mFadeOutAnimator.cancel();
        }
    }

    @Override
    public void didFirstVisuallyNonEmptyPaint(Tab tab) {
        if (canHideSplashScreen()) {
            hideSplash(tab, /* loadFailed= */ false);
        }
    }

    @Override
    public void onPageLoadFinished(Tab tab, GURL url) {
        if (canHideSplashScreen()) {
            hideSplash(tab, /* loadFailed= */ false);
        }
    }

    @Override
    public void onPageLoadFailed(Tab tab, int errorCode) {
        if (canHideSplashScreen()) {
            hideSplash(tab, /* loadFailed= */ true);
        }
    }

    @Override
    public void onInteractabilityChanged(Tab tab, boolean isInteractable) {
        if (!tab.isLoading()
                && isInteractable
                && mTabProvider.getInitialTabCreationMode() == TabCreationMode.RESTORED
                && canHideSplashScreen()) {
            hideSplash(tab, /* loadFailed= */ false);
        }
    }

    @Override
    public void onCrash(Tab tab) {
        hideSplash(tab, /* loadFailed= */ true);
    }

    private void showSplash() {
        mSplashShownTimestamp = SystemClock.elapsedRealtime();
        try (TraceEvent te = TraceEvent.scoped("SplashScreen.build")) {
            mSplashView = mDelegate.buildSplashView();
        }
        if (mSplashView == null) {
            mTabObserverRegistrar.unregisterActivityTabObserver(this);
            mLifecycleDispatcher.unregister(this);
            if (mIsWindowInitiallyTranslucent) {
                removeTranslucency();
            }
            return;
        }

        mParentView = mActivity.findViewById(android.R.id.content);
        mParentView.addView(mSplashView);

        recordTraceEventsShowedSplash();

        // If the client's activity is opaque, finishing the activities one after another may lead
        // to bottom activity showing itself in a short flash. The problem can be solved by bottom
        // activity killing the whole task.
        mFinishHandler.setShouldAttemptFinishingTask(true);
    }

    private boolean canHideSplashScreen() {
        return !mDelegate.shouldWaitForSubsequentPageLoadToHideSplash();
    }

    /** Hides the splash screen. */
    private void hideSplash(final Tab tab, boolean loadFailed) {
        if (mLifecycleDispatcher.isActivityFinishingOrDestroyed()) {
            return;
        }

        if (mIsWindowInitiallyTranslucent && !mRemovedTranslucency) {
            removeTranslucency();

            // Activity#convertFromTranslucent() incorrectly makes the Window opaque -
            // WindowStateAnimator#setOpaqueLocked(true) - when a surface view is
            // attached. This is fixed in http://b/126897750#comment14. The
            // Window currently has format PixelFormat.TRANSLUCENT (Set by the SurfaceView's
            // ViewParent#requestTransparentRegion() call). Swap the pixel format to
            // force an opacity change back to non-opaque.
            mActivity.getWindow().setFormat(PixelFormat.TRANSPARENT);

            mParentView.invalidate();
        }

        if (loadFailed) {
            animateHideSplash(tab);
            return;
        }
        // Delay hiding the splash screen till the compositor has finished drawing the next frame.
        // Without this callback we were seeing a short flash of white between the splash screen and
        // the web content (crbug.com/734500).
        mCompositorViewHolder
                .get()
                .getCompositorView()
                .surfaceRedrawNeededAsync(
                        () -> {
                            animateHideSplash(tab);
                        });
    }

    private void removeTranslucency() {
        // Removing translucency is important for performance, otherwise the windows under Chrome
        // will continue being drawn (e.g. launcher with wallpaper). Without removing translucency,
        // we also see visual glitches in the following cases:
        // - closing activity (example: https://crbug.com/856544#c41)
        // - send activity to the background (example: https://crbug.com/856544#c30)

        mRemovedTranslucency = true;

        // Removing the temporary translucency, so that underlying windows don't get drawn.
        try {
            Method method = Activity.class.getDeclaredMethod("convertFromTranslucent");
            method.setAccessible(true);
            method.invoke(mActivity);
        } catch (ReflectiveOperationException e) {
        }

        notifyTranslucencyRemoved();
    }

    private void animateHideSplash(final Tab tab) {
        if (mWasSplashHideAnimationStarted) return;

        mWasSplashHideAnimationStarted = true;
        mTabObserverRegistrar.unregisterActivityTabObserver(this);

        recordTraceEventsStartedHidingSplash();

        // Show browser UI in case we hid it in onPostInflationStartup().
        mActivity.findViewById(R.id.coordinator).setVisibility(View.VISIBLE);

        if (mSplashHideAnimationDurationMs == 0) {
            hideSplashNow(tab);
            return;
        }
        mFadeOutAnimator =
                mSplashView
                        .animate()
                        .alpha(0f)
                        .setDuration(mSplashHideAnimationDurationMs)
                        .withEndAction(
                                () -> {
                                    hideSplashNow(tab);
                                });
    }

    private void hideSplashNow(Tab tab) {
        mParentView.removeView(mSplashView);

        long splashHiddenTimestamp = SystemClock.elapsedRealtime();
        recordTraceEventsFinishedHidingSplash();

        assert mSplashShownTimestamp != 0;
        mDelegate.onSplashHidden(tab, mSplashShownTimestamp, splashHiddenTimestamp);
        notifySplashscreenHidden(mSplashShownTimestamp, splashHiddenTimestamp);

        mFinishHandler.setShouldAttemptFinishingTask(false);

        mLifecycleDispatcher.unregister(this);

        mDelegate = null;
        mSplashView = null;
        mFadeOutAnimator = null;
    }

    /** Register an observer for the splashscreen hidden/visible events. */
    public void addObserver(SplashscreenObserver observer) {
        mObservers.addObserver(observer);
    }

    /** Deregister an observer for the splashscreen hidden/visible events. */
    public void removeObserver(SplashscreenObserver observer) {
        mObservers.removeObserver(observer);
    }

    private void notifyTranslucencyRemoved() {
        for (SplashscreenObserver observer : mObservers) {
            observer.onTranslucencyRemoved();
        }
    }

    private void notifySplashscreenHidden(long startTimestamp, long endTimestmap) {
        for (SplashscreenObserver observer : mObservers) {
            observer.onSplashscreenHidden(startTimestamp, endTimestmap);
        }
        mObservers.clear();
    }

    private void recordTraceEventsShowedSplash() {
        SingleShotOnDrawListener.install(
                mParentView,
                () -> {
                    TraceEvent.startAsync("SplashScreen.visible", hashCode());
                });
    }

    private void recordTraceEventsStartedHidingSplash() {
        TraceEvent.startAsync("SplashScreen.hidingAnimation", hashCode());
    }

    private void recordTraceEventsFinishedHidingSplash() {
        TraceEvent.finishAsync("SplashScreen.hidingAnimation", hashCode());
        SingleShotOnDrawListener.install(
                mParentView,
                () -> {
                    TraceEvent.finishAsync("WebappSplashScreen.visible", hashCode());
                });
    }
}