chromium/chrome/android/javatests/src/org/chromium/chrome/browser/media/PictureInPictureActivityTest.java

// Copyright 2021 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.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.timeout;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;

import static org.chromium.base.test.util.Restriction.RESTRICTION_TYPE_NON_LOW_END_DEVICE;

import android.app.Activity;
import android.app.RemoteAction;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Rect;
import android.os.Build;
import android.os.Bundle;
import android.util.Rational;
import android.view.View;

import androidx.annotation.RequiresApi;
import androidx.test.filters.MediumTest;
import androidx.test.platform.app.InstrumentationRegistry;

import org.hamcrest.Matchers;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;

import org.chromium.base.ThreadUtils;
import org.chromium.base.test.util.Batch;
import org.chromium.base.test.util.CommandLineFlags;
import org.chromium.base.test.util.Criteria;
import org.chromium.base.test.util.CriteriaHelper;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.base.test.util.JniMocker;
import org.chromium.base.test.util.MinAndroidSdkLevel;
import org.chromium.base.test.util.Restriction;
import org.chromium.chrome.browser.flags.ChromeSwitches;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.chrome.test.ChromeTabbedActivityTestRule;
import org.chromium.chrome.test.util.ActivityTestUtils;
import org.chromium.content_public.browser.WebContents;
import org.chromium.content_public.browser.overlay_window.PlaybackState;
import org.chromium.content_public.browser.test.util.WebContentsUtils;
import org.chromium.media_session.mojom.MediaSessionAction;
import org.chromium.ui.test.util.DeviceRestriction;

import java.util.ArrayList;
import java.util.concurrent.Callable;
import java.util.concurrent.TimeoutException;

/** Tests for PictureInPictureActivity. */
@RunWith(ChromeJUnit4ClassRunner.class)
@Batch(Batch.PER_CLASS)
@CommandLineFlags.Add({ChromeSwitches.DISABLE_FIRST_RUN_EXPERIENCE})
@Restriction(DeviceRestriction.RESTRICTION_TYPE_NON_AUTO)
@RequiresApi(Build.VERSION_CODES.O)
public class PictureInPictureActivityTest {
    @Rule
    public ChromeTabbedActivityTestRule mActivityTestRule = new ChromeTabbedActivityTestRule();

    @Rule public JniMocker mMocker = new JniMocker();

    private static final long NATIVE_OVERLAY = 100L;
    private static final long PIP_TIMEOUT_MILLISECONDS = 10000L;

    @Mock private PictureInPictureActivity.Natives mNativeMock;

    private Tab mTab;

    // Source rect hint that we'll provide as the video element position.
    private Rect mSourceRectHint = new Rect(100, 200, 300, 400);

    // Helper to capture the source rect hint bounds that PictureInPictureActivity would like to use
    // for `makeEnterIntoPip`, if any.
    private PictureInPictureActivity.LaunchIntoPipHelper mLaunchIntoPipHelper =
            new PictureInPictureActivity.LaunchIntoPipHelper() {
                @Override
                public Bundle build(Context activityContext, Rect bounds) {
                    // Store the bounds in the parent class for easy reference.
                    mBounds = bounds;
                    return null;
                }
            };

    // Helper that we replace with `mLaunchIntoPipHelper`, so that we can restore it.
    private PictureInPictureActivity.LaunchIntoPipHelper mOriginalHelper;

    // Most recently provided bounds, if any.
    private Rect mBounds;

    @Before
    public void setUp() {
        MockitoAnnotations.initMocks(this);
        mActivityTestRule.startMainActivityOnBlankPage();
        mTab = mActivityTestRule.getActivity().getActivityTab();
        mMocker.mock(PictureInPictureActivityJni.TEST_HOOKS, mNativeMock);
        mOriginalHelper = PictureInPictureActivity.setLaunchIntoPipHelper(mLaunchIntoPipHelper);
    }

    @After
    public void teardown() {
        // Restore the original helper.
        PictureInPictureActivity.setLaunchIntoPipHelper(mOriginalHelper);
    }

    @Test
    @MediumTest
    @MinAndroidSdkLevel(Build.VERSION_CODES.O)
    public void testStartActivity() throws Throwable {
        PictureInPictureActivity activity = startPictureInPictureActivity();

        // The LaunchIntoPipHelper should have been called.
        Assert.assertTrue(mBounds != null);
        Criteria.checkThat(mBounds, Matchers.is(mSourceRectHint));
        testExitOn(activity, () -> activity.close());
    }

    @Test
    @MediumTest
    @MinAndroidSdkLevel(Build.VERSION_CODES.O)
    public void testExitOnClose() throws Throwable {
        PictureInPictureActivity activity = startPictureInPictureActivity();
        testExitOn(activity, () -> activity.close());
    }

    @Test
    @MediumTest
    @MinAndroidSdkLevel(Build.VERSION_CODES.O)
    public void testExitOnCrash() throws Throwable {
        PictureInPictureActivity activity = startPictureInPictureActivity();
        testExitOn(activity, () -> WebContentsUtils.simulateRendererKilled(getWebContents()));
    }

    @Test
    @MediumTest
    @DisabledTest(message = "b/353025645")
    @MinAndroidSdkLevel(Build.VERSION_CODES.O)
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testMakeEnterPictureInPictureWithBadSourceRect() throws Throwable {
        mSourceRectHint.left = -1;
        PictureInPictureActivity activity = startPictureInPictureActivity();
        // The pip helper should not be called with trivially bad bounds.
        Assert.assertTrue(mBounds == null);
        testExitOn(activity, () -> activity.close());
    }

    @Test
    @MediumTest
    @MinAndroidSdkLevel(Build.VERSION_CODES.O)
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testExitOnBackToTab() throws Throwable {
        PictureInPictureActivity activity = startPictureInPictureActivity();
        Configuration newConfig = activity.getResources().getConfiguration();
        testExitOn(
                activity,
                () ->
                        activity.onPictureInPictureModeChanged(
                                /* isInPictureInPictureMode= */ false, newConfig));
        verify(mNativeMock, times(1)).onBackToTab(NATIVE_OVERLAY);
    }

    @Test
    @MediumTest
    @MinAndroidSdkLevel(Build.VERSION_CODES.O)
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testResize() throws Throwable {
        PictureInPictureActivity activity = startPictureInPictureActivity();
        // Resize to some reasonable size, and verify that native is told about it.
        final int reasonableSize = 10;
        View view = activity.getViewForTesting();
        ThreadUtils.runOnUiThreadBlocking(() -> view.layout(0, 0, reasonableSize, reasonableSize));
        verify(mNativeMock, times(1))
                .onViewSizeChanged(NATIVE_OVERLAY, reasonableSize, reasonableSize);
        // An unreasonably large size should not generate a resize event.
        final int unreasonableSize = activity.getWindowAndroid().getDisplay().getDisplayWidth();
        ThreadUtils.runOnUiThreadBlocking(
                () -> view.layout(0, 0, unreasonableSize, unreasonableSize));
        verify(mNativeMock, times(0)).onViewSizeChanged(anyInt(), anyInt(), anyInt());
        testExitOn(activity, () -> activity.close());
    }

    @Test
    @MediumTest
    @MinAndroidSdkLevel(Build.VERSION_CODES.O)
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testMediaActions() throws Throwable {
        PictureInPictureActivity activity = startPictureInPictureActivity();
        PictureInPictureActivity.MediaActionButtonsManager manager =
                activity.mMediaActionsButtonsManager;

        activity.updateVisibleActions(new int[] {MediaSessionAction.PLAY});
        activity.setPlaybackState(PlaybackState.PAUSED);
        ArrayList<RemoteAction> actions = manager.getActionsForPictureInPictureParams();
        Assert.assertEquals(actions.size(), 1);
        Assert.assertEquals(actions.get(0), manager.mPlay);

        activity.setPlaybackState(PlaybackState.PLAYING);
        actions = manager.getActionsForPictureInPictureParams();
        Assert.assertEquals(actions.get(0), manager.mPause);

        // Both next track and previous track button should be visible when only one of them is
        // enabled. The one that is not handled should be visible and disabled.
        activity.updateVisibleActions(
                new int[] {MediaSessionAction.PLAY, MediaSessionAction.PREVIOUS_TRACK});
        actions = manager.getActionsForPictureInPictureParams();
        Assert.assertEquals(actions.size(), 3);
        Assert.assertEquals(actions.get(0), manager.mPreviousTrack);
        Assert.assertEquals(actions.get(2), manager.mNextTrack);
        Assert.assertTrue(actions.get(0).isEnabled());
        Assert.assertFalse(actions.get(2).isEnabled());

        // Both next slide and previous slide button should be visible when only one of them is
        // enabled. The one that is not handled should be visible and disabled.
        activity.updateVisibleActions(
                new int[] {MediaSessionAction.PLAY, MediaSessionAction.PREVIOUS_SLIDE});
        actions = manager.getActionsForPictureInPictureParams();
        Assert.assertEquals(actions.size(), 3);
        Assert.assertEquals(actions.get(0), manager.mPreviousSlide);
        Assert.assertEquals(actions.get(2), manager.mNextSlide);
        Assert.assertTrue(actions.get(0).isEnabled());
        Assert.assertFalse(actions.get(2).isEnabled());

        // When all actions are not handled, there should be a dummy action presented to prevent
        // android picture-in-picture from using default MediaSession.
        activity.updateVisibleActions(new int[] {});
        actions = manager.getActionsForPictureInPictureParams();
        Assert.assertEquals(actions.size(), 1);
        Assert.assertFalse(actions.get(0).isEnabled());
        testExitOn(activity, () -> activity.close());
    }

    @Test
    @MediumTest
    @MinAndroidSdkLevel(Build.VERSION_CODES.O)
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testMediaActionsForVideoConferencing() throws Throwable {
        PictureInPictureActivity activity = startPictureInPictureActivity();
        PictureInPictureActivity.MediaActionButtonsManager manager =
                activity.mMediaActionsButtonsManager;

        activity.updateVisibleActions(new int[] {MediaSessionAction.TOGGLE_MICROPHONE});
        ArrayList<RemoteAction> actions = manager.getActionsForPictureInPictureParams();
        Assert.assertEquals(actions.size(), 1);
        Assert.assertEquals(actions.get(0), manager.mMicrophone.getAction());

        activity.updateVisibleActions(new int[] {MediaSessionAction.TOGGLE_CAMERA});
        actions = manager.getActionsForPictureInPictureParams();
        Assert.assertEquals(actions.size(), 1);
        Assert.assertEquals(actions.get(0), manager.mCamera.getAction());

        activity.updateVisibleActions(new int[] {MediaSessionAction.HANG_UP});
        actions = manager.getActionsForPictureInPictureParams();
        Assert.assertEquals(actions.size(), 1);
        Assert.assertEquals(actions.get(0), manager.mHangUp);
        testExitOn(activity, () -> activity.close());
    }

    @Test
    @MediumTest
    @DisabledTest(message = "b/353357051")
    @MinAndroidSdkLevel(Build.VERSION_CODES.O)
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testActionsInSync() throws Throwable {
        PictureInPictureActivity activity = startPictureInPictureActivity();
        PictureInPictureActivity.MediaActionButtonsManager manager =
                activity.mMediaActionsButtonsManager;

        activity.setPlaybackState(PlaybackState.PLAYING);
        activity.setMicrophoneMuted(false);
        activity.setCameraState(true);

        manager.mMicrophone.getAction().getActionIntent().send();
        manager.mCamera.getAction().getActionIntent().send();
        manager.mPause.getActionIntent().send();

        verify(mNativeMock, timeout(CriteriaHelper.DEFAULT_MAX_TIME_TO_POLL).times(1))
                .togglePlayPause(eq(NATIVE_OVERLAY), eq(false));
        verify(mNativeMock, timeout(CriteriaHelper.DEFAULT_MAX_TIME_TO_POLL).times(1))
                .toggleMicrophone(eq(NATIVE_OVERLAY), eq(false));
        verify(mNativeMock, timeout(CriteriaHelper.DEFAULT_MAX_TIME_TO_POLL).times(1))
                .toggleCamera(eq(NATIVE_OVERLAY), eq(false));

        activity.setPlaybackState(PlaybackState.PAUSED);
        activity.setMicrophoneMuted(true);
        activity.setCameraState(false);
        manager.mPlay.getActionIntent().send();
        manager.mMicrophone.getAction().getActionIntent().send();
        manager.mCamera.getAction().getActionIntent().send();

        verify(mNativeMock, timeout(CriteriaHelper.DEFAULT_MAX_TIME_TO_POLL).times(1))
                .togglePlayPause(eq(NATIVE_OVERLAY), eq(true));
        verify(mNativeMock, timeout(CriteriaHelper.DEFAULT_MAX_TIME_TO_POLL).times(1))
                .toggleMicrophone(eq(NATIVE_OVERLAY), eq(true));
        verify(mNativeMock, timeout(CriteriaHelper.DEFAULT_MAX_TIME_TO_POLL).times(1))
                .toggleCamera(eq(NATIVE_OVERLAY), eq(true));

        testExitOn(activity, () -> activity.close());
    }

    @Test
    @MediumTest
    @MinAndroidSdkLevel(Build.VERSION_CODES.O)
    @Restriction(RESTRICTION_TYPE_NON_LOW_END_DEVICE)
    public void testNotifyNativeWhenTabClose() throws Throwable {
        PictureInPictureActivity activity = startPictureInPictureActivity();
        testExitOn(activity, () -> mTab.setClosing(/* closing= */ true));
        verify(mNativeMock, times(1)).destroy(NATIVE_OVERLAY);
    }

    private WebContents getWebContents() {
        return mActivityTestRule.getActivity().getCurrentWebContents();
    }

    private void testExitOn(Activity activity, Runnable runnable) throws Throwable {
        ThreadUtils.runOnUiThreadBlocking(() -> runnable.run());

        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(
                            activity == null || activity.isDestroyed(), Matchers.is(true));
                },
                PIP_TIMEOUT_MILLISECONDS,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);
    }

    private PictureInPictureActivity startPictureInPictureActivity() throws Exception {
        PictureInPictureActivity activity =
                ActivityTestUtils.waitForActivity(
                        InstrumentationRegistry.getInstrumentation(),
                        PictureInPictureActivity.class,
                        new Callable<Void>() {
                            @Override
                            public Void call() throws TimeoutException {
                                ThreadUtils.runOnUiThreadBlocking(
                                        () ->
                                                PictureInPictureActivity.createActivity(
                                                        NATIVE_OVERLAY,
                                                        mTab,
                                                        mSourceRectHint.left,
                                                        mSourceRectHint.top,
                                                        mSourceRectHint.width(),
                                                        mSourceRectHint.height()));
                                return null;
                            }
                        });

        verify(mNativeMock, timeout(500).times(1))
                .onActivityStart(eq(NATIVE_OVERLAY), eq(activity), any());

        CriteriaHelper.pollUiThread(
                () -> {
                    Criteria.checkThat(activity.isInPictureInPictureMode(), Matchers.is(true));
                },
                PIP_TIMEOUT_MILLISECONDS,
                CriteriaHelper.DEFAULT_POLLING_INTERVAL);

        Rational ratio = activity.getAspectRatio();
        Criteria.checkThat(
                ratio,
                Matchers.is(new Rational(mSourceRectHint.width(), mSourceRectHint.height())));

        return activity;
    }
}