// Copyright 2014 The Chromium Authors. All rights reserved. // 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.fullscreen; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ObjectAnimator; import android.app.Activity; import android.content.res.Resources; import android.os.Handler; import android.os.Message; import android.os.SystemClock; import android.util.Property; import android.view.Gravity; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewGroup.LayoutParams; import android.view.Window; import android.widget.FrameLayout; import org.chromium.base.ActivityState; import org.chromium.base.ApplicationStatus; import org.chromium.base.ApplicationStatus.ActivityStateListener; import org.chromium.base.BaseChromiumApplication; import org.chromium.base.BaseChromiumApplication.WindowFocusChangedListener; import org.chromium.base.ThreadUtils; import org.chromium.base.TraceEvent; import org.chromium.base.VisibleForTesting; import org.chromium.chrome.browser.fullscreen.FullscreenHtmlApiHandler.FullscreenHtmlApiDelegate; import org.chromium.chrome.browser.tab.Tab; import org.chromium.chrome.browser.tabmodel.TabModelSelector; import org.chromium.chrome.browser.widget.ControlContainer; import org.chromium.content.browser.ContentVideoView; import org.chromium.content.browser.ContentViewCore; import org.chromium.content_public.common.TopControlsState; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.HashSet; /** * A class that manages control and content views to create the fullscreen mode. */ public class ChromeFullscreenManager extends FullscreenManager implements ActivityStateListener, WindowFocusChangedListener { // Minimum showtime of the toolbar (in ms). private static final long MINIMUM_SHOW_DURATION_MS = 3000; // Maximum length of the slide in/out animation of the toolbar (in ms). private static final long MAX_ANIMATION_DURATION_MS = 500; private static final int MSG_ID_HIDE_CONTROLS = 1; private final HashSet<Integer> mPersistentControlTokens = new HashSet<Integer>(); private final Activity mActivity; private final Window mWindow; private final Handler mHandler; private final int mControlContainerHeight; private final ControlContainer mControlContainer; private long mMinShowNotificationMs = MINIMUM_SHOW_DURATION_MS; private long mMaxAnimationDurationMs = MAX_ANIMATION_DURATION_MS; private float mBrowserControlOffset = Float.NaN; private float mRendererControlOffset = Float.NaN; private float mRendererContentOffset; private float mPreviousContentOffset = Float.NaN; private float mControlOffset; private float mPreviousControlOffset; private boolean mIsEnteringPersistentModeState; private boolean mInGesture; private boolean mContentViewScrolling; private int mPersistentControlsCurrentToken; private long mCurrentShowTime; private ObjectAnimator mControlAnimation; private boolean mCurrentAnimationIsShowing; private boolean mDisableBrowserOverride; private boolean mTopControlsPermanentlyHidden; private boolean mTopControlsAndroidViewHidden; private final boolean mSupportsBrowserOverride; private final ArrayList<FullscreenListener> mListeners = new ArrayList<FullscreenListener>(); /** * A listener that gets notified of changes to the fullscreen state. */ public interface FullscreenListener { /** * Called whenever the content's offset changes. * @param offset The new offset of the content from the top of the screen. */ public void onContentOffsetChanged(float offset); /** * Called whenever the content's visible offset changes. * @param offset The new offset of the visible content from the top of the screen. * @param needsAnimate Whether the caller is driving an animation with further updates. */ public void onVisibleContentOffsetChanged(float offset, boolean needsAnimate); /** * Called when a ContentVideoView is created/destroyed. * @param enabled Whether to enter or leave overlay video mode. */ public void onToggleOverlayVideoMode(boolean enabled); } private class ControlsOffsetProperty extends Property<ChromeFullscreenManager, Float> { public ControlsOffsetProperty() { super(Float.class, "controlsOffset"); } @Override public Float get(ChromeFullscreenManager object) { return getControlOffset(); } @Override public void set(ChromeFullscreenManager manager, Float offset) { if (mDisableBrowserOverride) return; float browserOffset = offset.floatValue(); if (Float.compare(mBrowserControlOffset, browserOffset) == 0) return; mBrowserControlOffset = browserOffset; manager.updateControlOffset(); manager.updateVisuals(); } } private final Runnable mUpdateVisibilityRunnable = new Runnable() { @Override public void run() { int visibility = shouldShowAndroidControls() ? View.VISIBLE : View.INVISIBLE; if (mControlContainer.getView().getVisibility() == visibility) return; // requestLayout is required to trigger a new gatherTransparentRegion(), which // only occurs together with a layout and let's SurfaceFlinger trim overlays. // This may be almost equivalent to using View.GONE, but we still use View.INVISIBLE // since drawing caches etc. won't be destroyed, and the layout may be less expensive. mControlContainer.getView().setVisibility(visibility); mControlContainer.getView().requestLayout(); } }; // This static inner class holds a WeakReference to the outer object, to avoid triggering the // lint HandlerLeak warning. private static class FullscreenHandler extends Handler { private final WeakReference<ChromeFullscreenManager> mChromeFullscreenManager; public FullscreenHandler(ChromeFullscreenManager chromeFullscreenManager) { mChromeFullscreenManager = new WeakReference<ChromeFullscreenManager>( chromeFullscreenManager); } @Override public void handleMessage(Message msg) { if (msg == null) return; ChromeFullscreenManager chromeFullscreenManager = mChromeFullscreenManager.get(); if (chromeFullscreenManager == null) return; switch (msg.what) { case MSG_ID_HIDE_CONTROLS: chromeFullscreenManager.update(false); break; default: assert false : "Unexpected message for ID: " + msg.what; break; } } } /** * Creates an instance of the fullscreen mode manager. * @param activity The activity that supports fullscreen. * @param controlContainer Container holding the controls (Toolbar). * @param modelSelector The model selector providing access to the current tab. * @param resControlContainerHeight The dimension resource ID for the control container height. * @param supportsBrowserOverride Whether we want to disable the token system used by the browser. */ public ChromeFullscreenManager(Activity activity, ControlContainer controlContainer, TabModelSelector modelSelector, int resControlContainerHeight, boolean supportsBrowserOverride) { super(activity.getWindow(), modelSelector); mActivity = activity; ApplicationStatus.registerStateListenerForActivity(this, activity); ((BaseChromiumApplication) activity.getApplication()) .registerWindowFocusChangedListener(this); mWindow = activity.getWindow(); mHandler = new FullscreenHandler(this); assert controlContainer != null; mControlContainer = controlContainer; Resources resources = mWindow.getContext().getResources(); mControlContainerHeight = resources.getDimensionPixelSize(resControlContainerHeight); mRendererContentOffset = mControlContainerHeight; mSupportsBrowserOverride = supportsBrowserOverride; updateControlOffset(); } @Override public void onActivityStateChange(Activity activity, int newState) { if (newState == ActivityState.STOPPED) { // Exit fullscreen in onStop to ensure the system UI flags are set correctly when // showing again (on JB MR2+ builds, the omnibox would be covered by the // notification bar when this was done in onStart()). setPersistentFullscreenMode(false); } else if (newState == ActivityState.STARTED) { showControlsTransient(); } else if (newState == ActivityState.DESTROYED) { ApplicationStatus.unregisterActivityStateListener(this); ((BaseChromiumApplication) mWindow.getContext().getApplicationContext()) .unregisterWindowFocusChangedListener(this); } } @Override public void onWindowFocusChanged(Activity activity, boolean hasFocus) { if (mActivity != activity) return; onWindowFocusChanged(hasFocus); ContentVideoView videoView = ContentVideoView.getContentVideoView(); if (videoView != null) { videoView.onFullscreenWindowFocused(); } } @Override protected FullscreenHtmlApiDelegate createApiDelegate() { return new FullscreenHtmlApiDelegate() { @Override public void onEnterFullscreen() { Tab tab = getActiveTab(); if (getControlOffset() == -mControlContainerHeight) { // The top controls are currently hidden. getHtmlApiHandler().enterFullscreen(tab); } else { // We should hide top controls first. mIsEnteringPersistentModeState = true; tab.updateFullscreenEnabledState(); } } @Override public boolean cancelPendingEnterFullscreen() { boolean wasPending = mIsEnteringPersistentModeState; mIsEnteringPersistentModeState = false; return wasPending; } @Override public void onFullscreenExited(Tab tab) { // At this point, top controls are hidden. Show top controls only if it's // permitted. tab.updateTopControlsState(TopControlsState.SHOWN, true); } @Override public boolean shouldShowNotificationToast() { return !isOverlayVideoMode(); } }; } /** * Disables the ability for the browser to override the renderer provided top controls * position for testing. */ @VisibleForTesting public void disableBrowserOverrideForTest() { ThreadUtils.assertOnUiThread(); mDisableBrowserOverride = true; mPersistentControlTokens.clear(); mHandler.removeMessages(MSG_ID_HIDE_CONTROLS); if (mControlAnimation != null) { mControlAnimation.cancel(); mControlAnimation = null; } mBrowserControlOffset = Float.NaN; updateVisuals(); } /** * Allows tests to override the animation durations for faster tests. * @param minShowDuration The minimum time the controls must be shown. * @param maxAnimationDuration The maximum animation time to show/hide the controls. */ @VisibleForTesting public void setAnimationDurationsForTest(long minShowDuration, long maxAnimationDuration) { mMinShowNotificationMs = minShowDuration; mMaxAnimationDurationMs = maxAnimationDuration; } @Override public void showControlsTransient() { if (!mSupportsBrowserOverride) return; if (mPersistentControlTokens.isEmpty()) update(true); } @Override public int showControlsPersistent() { if (!mSupportsBrowserOverride) return INVALID_TOKEN; int token = mPersistentControlsCurrentToken++; mPersistentControlTokens.add(token); if (mPersistentControlTokens.size() == 1) update(true); return token; } @Override public int showControlsPersistentAndClearOldToken(int oldToken) { if (!mSupportsBrowserOverride) return INVALID_TOKEN; if (oldToken != INVALID_TOKEN) mPersistentControlTokens.remove(oldToken); return showControlsPersistent(); } @Override public void hideControlsPersistent(int token) { if (!mSupportsBrowserOverride) return; if (mPersistentControlTokens.remove(token) && mPersistentControlTokens.isEmpty()) { update(false); } } /** * @param remove Whether or not to forcefully remove the toolbar. */ public void setTopControlsPermamentlyHidden(boolean remove) { if (remove == mTopControlsPermanentlyHidden) return; mTopControlsPermanentlyHidden = remove; updateVisuals(); } /** * @return Whether or not the toolbar is forcefully being removed. */ public boolean areTopControlsPermanentlyHidden() { return mTopControlsPermanentlyHidden; } /** * @return Whether the top controls should be drawn as a texture. */ public boolean drawControlsAsTexture() { return getControlOffset() > -mControlContainerHeight; } @Override public int getTopControlsHeight() { return mControlContainerHeight; } @Override public float getContentOffset() { if (mTopControlsPermanentlyHidden) return 0; return rendererContentOffset(); } /** * @return The offset of the controls from the top of the screen. */ public float getControlOffset() { if (mTopControlsPermanentlyHidden) return -getTopControlsHeight(); return mControlOffset; } /** * @return The toolbar control container. */ public ControlContainer getControlContainer() { return mControlContainer; } @SuppressWarnings("SelfEquality") private void updateControlOffset() { float offset = 0; // Inline Float.isNan with "x != x": final boolean isNaNBrowserControlOffset = mBrowserControlOffset != mBrowserControlOffset; final float rendererControlOffset = rendererControlOffset(); final boolean isNaNRendererControlOffset = rendererControlOffset != rendererControlOffset; if (!isNaNBrowserControlOffset || !isNaNRendererControlOffset) { offset = Math.max( isNaNBrowserControlOffset ? -mControlContainerHeight : mBrowserControlOffset, isNaNRendererControlOffset ? -mControlContainerHeight : rendererControlOffset); } mControlOffset = offset; } @Override public void setOverlayVideoMode(boolean enabled) { super.setOverlayVideoMode(enabled); for (int i = 0; i < mListeners.size(); i++) { mListeners.get(i).onToggleOverlayVideoMode(enabled); } } /** * @return Whether the browser has a control offset override. */ @VisibleForTesting public boolean hasBrowserControlOffsetOverride() { return !Float.isNaN(mBrowserControlOffset) || mControlAnimation != null || !mPersistentControlTokens.isEmpty(); } /** * Returns how tall the opaque portion of the control container is. */ public float controlContainerHeight() { return mControlContainerHeight; } private float rendererContentOffset() { return mRendererContentOffset; } private float rendererControlOffset() { return mRendererControlOffset; } /** * @return The visible offset of the content from the top of the screen. */ public float getVisibleContentOffset() { return mControlContainerHeight + getControlOffset(); } /** * @param listener The {@link FullscreenListener} to be notified of fullscreen changes. */ public void addListener(FullscreenListener listener) { if (!mListeners.contains(listener)) mListeners.add(listener); } /** * @param listener The {@link FullscreenListener} to no longer be notified of fullscreen * changes. */ public void removeListener(FullscreenListener listener) { mListeners.remove(listener); } /** * Updates the content view's viewport size to have it render the content correctly. * * @param viewCore The ContentViewCore to update. */ public void updateContentViewViewportSize(ContentViewCore viewCore) { if (viewCore == null) return; if (mInGesture || mContentViewScrolling) return; // Update content viewport size only when the top controls are not animating. int contentOffset = (int) rendererContentOffset(); if (contentOffset != 0 && contentOffset != mControlContainerHeight) return; viewCore.setTopControlsHeight(mControlContainerHeight, contentOffset > 0); } @Override public void updateContentViewChildrenState() { ContentViewCore contentViewCore = getActiveContentViewCore(); if (contentViewCore == null) return; ViewGroup view = contentViewCore.getContainerView(); float topViewsTranslation = (getControlOffset() + mControlContainerHeight); applyTranslationToTopChildViews(view, topViewsTranslation); applyMarginToFullChildViews(view, topViewsTranslation); updateContentViewViewportSize(contentViewCore); } /** * Utility routine for ensuring visibility updates are synchronized with * animation, preventing message loop stalls due to untimely invalidation. */ private void scheduleVisibilityUpdate() { final int desiredVisibility = shouldShowAndroidControls() ? View.VISIBLE : View.INVISIBLE; if (mControlContainer.getView().getVisibility() == desiredVisibility) return; mControlContainer.getView().removeCallbacks(mUpdateVisibilityRunnable); mControlContainer.getView().postOnAnimation(mUpdateVisibilityRunnable); } private void updateVisuals() { TraceEvent.begin("FullscreenManager:updateVisuals"); float offset = getControlOffset(); if (Float.compare(mPreviousControlOffset, offset) != 0) { mPreviousControlOffset = offset; scheduleVisibilityUpdate(); if (shouldShowAndroidControls()) { mControlContainer.getView().setTranslationY(getControlOffset()); } // Whether we need the compositor to draw again to update our animation. // Should be |false| when the top controls are only moved through the page scrolling. boolean needsAnimate = mControlAnimation != null || shouldShowAndroidControls(); for (int i = 0; i < mListeners.size(); i++) { mListeners.get(i).onVisibleContentOffsetChanged( getVisibleContentOffset(), needsAnimate); } } final Tab tab = getActiveTab(); if (tab != null && offset == -mControlContainerHeight && mIsEnteringPersistentModeState) { getHtmlApiHandler().enterFullscreen(tab); mIsEnteringPersistentModeState = false; } updateContentViewChildrenState(); float contentOffset = getContentOffset(); if (Float.compare(mPreviousContentOffset, contentOffset) != 0) { for (int i = 0; i < mListeners.size(); i++) { mListeners.get(i).onContentOffsetChanged(contentOffset); } mPreviousContentOffset = contentOffset; } TraceEvent.end("FullscreenManager:updateVisuals"); } /** * @param hide Whether or not to force the top controls Android view to hide. If this is * {@code false} the top controls Android view will show/hide based on position, if * it is {@code true} the top controls Android view will always be hidden. */ public void setHideTopControlsAndroidView(boolean hide) { if (mTopControlsAndroidViewHidden == hide) return; mTopControlsAndroidViewHidden = hide; scheduleVisibilityUpdate(); } private boolean shouldShowAndroidControls() { if (mTopControlsAndroidViewHidden) return false; boolean showControls = getControlOffset() == 0; ContentViewCore contentViewCore = getActiveContentViewCore(); if (contentViewCore == null) return showControls; ViewGroup contentView = contentViewCore.getContainerView(); for (int i = 0; i < contentView.getChildCount(); i++) { View child = contentView.getChildAt(i); if (!(child.getLayoutParams() instanceof FrameLayout.LayoutParams)) continue; FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) child.getLayoutParams(); if (Gravity.TOP == (layoutParams.gravity & Gravity.FILL_VERTICAL)) { showControls = true; break; } } showControls |= !mPersistentControlTokens.isEmpty(); return showControls; } private void applyMarginToFullChildViews(ViewGroup contentView, float margin) { for (int i = 0; i < contentView.getChildCount(); i++) { View child = contentView.getChildAt(i); if (!(child.getLayoutParams() instanceof FrameLayout.LayoutParams)) continue; FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) child.getLayoutParams(); if (layoutParams.height == LayoutParams.MATCH_PARENT && layoutParams.topMargin != (int) margin) { layoutParams.topMargin = (int) margin; child.requestLayout(); TraceEvent.instant("FullscreenManager:child.requestLayout()"); } } } private void applyTranslationToTopChildViews(ViewGroup contentView, float translation) { for (int i = 0; i < contentView.getChildCount(); i++) { View child = contentView.getChildAt(i); if (!(child.getLayoutParams() instanceof FrameLayout.LayoutParams)) continue; FrameLayout.LayoutParams layoutParams = (FrameLayout.LayoutParams) child.getLayoutParams(); if (Gravity.TOP == (layoutParams.gravity & Gravity.FILL_VERTICAL)) { child.setTranslationY(translation); TraceEvent.instant("FullscreenManager:child.setTranslationY()"); } } } private Tab getActiveTab() { Tab tab = getTabModelSelector().getCurrentTab(); return tab; } private ContentViewCore getActiveContentViewCore() { Tab tab = getActiveTab(); return tab != null ? tab.getContentViewCore() : null; } @Override public void setPositionsForTabToNonFullscreen() { Tab tab = getActiveTab(); if (tab == null || tab.isShowingTopControlsEnabled()) { setPositionsForTab(0, mControlContainerHeight); } else { setPositionsForTab(-mControlContainerHeight, 0); } } @Override public void setPositionsForTab(float controlsOffset, float contentOffset) { float rendererControlOffset = Math.round(Math.max(controlsOffset, -mControlContainerHeight)); float rendererContentOffset = Math.min( Math.round(contentOffset), rendererControlOffset + mControlContainerHeight); if (Float.compare(rendererControlOffset, mRendererControlOffset) == 0 && Float.compare(rendererContentOffset, mRendererContentOffset) == 0) { return; } mRendererControlOffset = rendererControlOffset; mRendererContentOffset = rendererContentOffset; updateControlOffset(); if (mControlAnimation == null) updateVisuals(); } /** * @param e The dispatched motion event * @return Whether or not this motion event is in the top control container area and should be * consumed. */ public boolean onInterceptMotionEvent(MotionEvent e) { return e.getY() < getControlOffset() + mControlContainerHeight && !mTopControlsAndroidViewHidden; } /** * Notifies the fullscreen manager that a motion event has occurred. * @param e The dispatched motion event. */ public void onMotionEvent(MotionEvent e) { int eventAction = e.getActionMasked(); if (eventAction == MotionEvent.ACTION_DOWN || eventAction == MotionEvent.ACTION_POINTER_DOWN) { mInGesture = true; // TODO(qinmin): Probably there is no need to hide the toast as it will go away // by itself. getHtmlApiHandler().hideNotificationToast(); } else if (eventAction == MotionEvent.ACTION_CANCEL || eventAction == MotionEvent.ACTION_UP) { mInGesture = false; updateVisuals(); } } private void update(boolean show) { // On forced show/hide, reset the flags that may suppress ContentView resize. // As this method is also called when tab is switched, this also cleanup the scrolling // flag set based on the previous ContentView's scrolling state. mInGesture = false; mContentViewScrolling = false; if (show) mCurrentShowTime = SystemClock.uptimeMillis(); boolean postHideMessage = false; if (!show) { if (mControlAnimation != null && mCurrentAnimationIsShowing) { postHideMessage = true; } else { long timeDelta = SystemClock.uptimeMillis() - mCurrentShowTime; animateIfNecessary(false, Math.max(mMinShowNotificationMs - timeDelta, 0)); } } else { animateIfNecessary(true, 0); if (mPersistentControlTokens.isEmpty()) postHideMessage = true; } mHandler.removeMessages(MSG_ID_HIDE_CONTROLS); if (postHideMessage) { long timeDelta = SystemClock.uptimeMillis() - mCurrentShowTime; mHandler.sendEmptyMessageDelayed( MSG_ID_HIDE_CONTROLS, Math.max(mMinShowNotificationMs - timeDelta, 0)); } } private void animateIfNecessary(final boolean show, long startDelay) { if (mControlAnimation != null) { if (!mControlAnimation.isRunning() || mCurrentAnimationIsShowing != show) { mControlAnimation.cancel(); mControlAnimation = null; } else { return; } } float destination = show ? 0 : -mControlContainerHeight; long duration = (long) (mMaxAnimationDurationMs * Math.abs((destination - getControlOffset()) / mControlContainerHeight)); mControlAnimation = ObjectAnimator.ofFloat(this, new ControlsOffsetProperty(), destination); mControlAnimation.addListener(new AnimatorListenerAdapter() { private boolean mCanceled = false; @Override public void onAnimationCancel(Animator anim) { mCanceled = true; } @Override public void onAnimationEnd(Animator animation) { if (!show && !mCanceled) mBrowserControlOffset = Float.NaN; mControlAnimation = null; } }); mControlAnimation.setStartDelay(startDelay); mControlAnimation.setDuration(duration); mControlAnimation.start(); mCurrentAnimationIsShowing = show; } @Override public void onContentViewScrollingStateChanged(boolean scrolling) { mContentViewScrolling = scrolling; if (!scrolling) updateVisuals(); } }