chromium/chrome/android/junit/src/org/chromium/chrome/browser/media/FullscreenVideoPictureInPictureControllerUnitTest.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.media;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import android.app.Activity;
import android.app.ActivityManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowLog;
import org.robolectric.shadows.ShadowPackageManager;
import org.robolectric.shadows.ShadowSystemClock;

import org.chromium.base.ContextUtils;
import org.chromium.base.UserDataHost;
import org.chromium.base.task.test.ShadowPostTask;
import org.chromium.base.test.BaseRobolectricTestRunner;
import org.chromium.chrome.browser.ActivityTabProvider;
import org.chromium.chrome.browser.AppHooks;
import org.chromium.chrome.browser.fullscreen.FullscreenManager;
import org.chromium.chrome.browser.infobar.InfoBarContainer;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.content_public.browser.MediaSession;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.WebContentsObserver;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

/** Test FullscreenVideoPictureInPictureController. */
@RunWith(BaseRobolectricTestRunner.class)
@Config(
        sdk = Build.VERSION_CODES.O,
        shadows = {ShadowPackageManager.class, ShadowPostTask.class, ShadowSystemClock.class})
public class FullscreenVideoPictureInPictureControllerUnitTest {
    private static final int TAB_ID = 0;

    @Mock private Activity mActivity;
    @Mock private ActivityTabProvider mActivityTabProvider;
    @Mock private FullscreenManager mFullscreenManager;
    @Mock private Tab mTab;
    @Mock private WebContents mWebContents;
    @Mock private InfoBarContainer mInfoBarContainer;
    @Mock private MediaSession mMediaSession;

    // Not a mock, since it's just a container and `final` anyway.
    private UserDataHost mUserDataHost = new UserDataHost();

    private FullscreenVideoPictureInPictureController mController;

    @Captor private ArgumentCaptor<FullscreenManager.Observer> mFullscreenObserverCaptor;
    @Captor private ArgumentCaptor<WebContentsObserver> mWebContentsObserverCaptor;

    /** List of tasks that were posted, including with delay. Run with runUntilIdle(). */
    private List<Runnable> mRunnables = new ArrayList<>();

    /** Class to be tested, extended to allow us to provide some hooks. */
    class FullscreenVideoPictureInPictureControllerWithOverrides
            extends FullscreenVideoPictureInPictureController {
        public FullscreenVideoPictureInPictureControllerWithOverrides(
                Activity activity,
                ActivityTabProvider activityTabProvider,
                FullscreenManager fullscreenManager) {
            super(activity, activityTabProvider, fullscreenManager);
        }

        @Override
        InfoBarContainer getInfoBarContainerForTab(Tab tab) {
            return mInfoBarContainer;
        }

        @Override
        MediaSession getMediaSession() {
            return mMediaSession;
        }

        @Override
        void assertLibraryLoaderIsInitialized() {}
    }

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        ShadowLog.stream = System.out;

        ShadowPostTask.setTestImpl(
                new ShadowPostTask.TestImpl() {
                    @Override
                    public void postDelayedTask(int taskTraits, Runnable task, long delay) {
                        mRunnables.add(task);
                    }
                });

        Context context = ContextUtils.getApplicationContext();
        ShadowPackageManager shadowPackageManager = Shadows.shadowOf(context.getPackageManager());
        shadowPackageManager.setSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE, true);

        when(mActivity.getSystemService(Context.ACTIVITY_SERVICE))
                .thenReturn((ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE));
        when(mActivity.getPackageManager()).thenReturn(context.getPackageManager());
        when(mActivityTabProvider.get()).thenReturn(mTab);
        when(mTab.getWebContents()).thenReturn(mWebContents);
        when(mTab.getUserDataHost()).thenReturn(mUserDataHost);

        mController =
                new FullscreenVideoPictureInPictureControllerWithOverrides(
                        mActivity, mActivityTabProvider, mFullscreenManager);
    }

    @After
    public void tearDown() {
        AppHooks.setInstanceForTesting(null);
    }

    /** Set up the mocks to claim that there is / is not full screen video playback */
    private void setHasFullscreenVideo(boolean hasVideo) {
        when(mWebContents.hasActiveEffectivelyFullscreenVideo()).thenReturn(hasVideo);
        when(mWebContents.isPictureInPictureAllowedForFullscreenVideo()).thenReturn(hasVideo);
    }

    /** Run any runnables, including any delayed ones. */
    private void runUntilIdle() {
        // In case the tasks post more tasks, start a new list.
        List<Runnable> runnables = mRunnables;
        mRunnables = new ArrayList<>();

        for (Runnable r : runnables) {
            r.run();
        }
    }

    /** Verify that full screen video will try to enter PiP */
    @Test
    public void attemptPictureInPictureSuccessfully() {
        setHasFullscreenVideo(true);
        mController.attemptPictureInPicture();
        verify(mActivity, times(1)).enterPictureInPictureMode(any());
    }

    /** Verify that lack of full screen video results in no pip */
    @Test
    public void pictureInPictureFailsWithoutVideo() {
        setHasFullscreenVideo(false);
        mController.attemptPictureInPicture();
        verify(mActivity, times(0)).enterPictureInPictureMode(any());
    }

    /** After starting pip, dismiss should move the task to back if it's been long enough. */
    @Test
    public void pictureInPictureIsDismissedAfterEnoughTime() {
        mController.onEnteredPictureInPictureMode();
        verify(mFullscreenManager).addObserver(mFullscreenObserverCaptor.capture());
        // After a second, assume that it'll exit immediately.
        ShadowSystemClock.advanceBy(
                FullscreenVideoPictureInPictureController.MIN_EXIT_DELAY_MILLIS + 10L,
                TimeUnit.MILLISECONDS);
        mFullscreenObserverCaptor.getValue().onExitFullscreen(mTab);
        verify(mActivity, times(1)).moveTaskToBack(true);
        // Verify that a second call does not dismiss a second time.
        mFullscreenObserverCaptor.getValue().onExitFullscreen(mTab);
        verify(mActivity, times(1)).moveTaskToBack(true);
    }

    /** After starting pip, dismiss should not move the task to back if it hasn't been long enough. */
    @Test
    public void pictureInPictureIsNotDismissedImmediately() {
        mController.onEnteredPictureInPictureMode();
        verify(mFullscreenManager).addObserver(mFullscreenObserverCaptor.capture());
        // Leave the clock at 0, which should cause it to post rather than call back.
        mFullscreenObserverCaptor.getValue().onExitFullscreen(mTab);
        verify(mActivity, times(0)).moveTaskToBack(true);
        // Advance the clock so that it has been long enough, and run all delayed tasks.
        ShadowSystemClock.advanceBy(
                FullscreenVideoPictureInPictureController.MIN_EXIT_DELAY_MILLIS + 10L,
                TimeUnit.MILLISECONDS);
        runUntilIdle();
        verify(mActivity, times(1)).moveTaskToBack(true);
    }

    /** Stash will pause the video, then restart it when un-stashed. */
    @Test
    public void pictureInPicturePausesAndResumesWhenStashed() {
        mController.onEnteredPictureInPictureMode();
        verify(mWebContents).addObserver(mWebContentsObserverCaptor.capture());

        // Stash while media is playing.
        mWebContentsObserverCaptor.getValue().mediaStartedPlaying();
        mController.onStashReported(true);
        verify(mMediaSession, times(1)).suspend();
        mWebContentsObserverCaptor.getValue().mediaStoppedPlaying();

        // Un-stash while media is still paused.
        mController.onStashReported(false);
        verify(mMediaSession, times(1)).resume();
    }

    /**
     * Stash will neither pause on stash nor resume on unstash video that's paused when the pip
     * window is stashed.
     */
    @Test
    public void pictureInPictureDoesNotChangeAlreadyPausedVideoOnStash() {
        mController.onEnteredPictureInPictureMode();
        verify(mWebContents).addObserver(mWebContentsObserverCaptor.capture());
        // Make sure that the video is paused.
        mWebContentsObserverCaptor.getValue().mediaStoppedPlaying();

        // Stashing paused video should do nothing.
        mController.onStashReported(true);
        verify(mMediaSession, times(0)).suspend();

        // Un-stash should also do nothing.
        mController.onStashReported(false);
        verify(mMediaSession, times(0)).resume();
    }

    /** If video starts playing during a normal stash, unstash should no-op. */
    @Test
    public void pictureInPictureDoesNotResumeOnUnstashIfAlreadyPlaying() {
        mController.onEnteredPictureInPictureMode();
        verify(mWebContents).addObserver(mWebContentsObserverCaptor.capture());
        // Stash normally.
        mWebContentsObserverCaptor.getValue().mediaStartedPlaying();
        mController.onStashReported(true);
        verify(mMediaSession, times(1)).suspend();
        mWebContentsObserverCaptor.getValue().mediaStoppedPlaying();

        // Restart playback while still stashed.
        mWebContentsObserverCaptor.getValue().mediaStartedPlaying();

        // Un-stash should do nothing since there's nothing to do.
        mController.onStashReported(false);
        verify(mMediaSession, times(0)).resume();
    }
}