Bug 1349523 - Add support for playing videos in Picture-in-picture mode; r?jchen
MozReview-Commit-ID: DKlBFRo9q8t
--- a/mobile/android/base/AndroidManifest.xml.in
+++ b/mobile/android/base/AndroidManifest.xml.in
@@ -53,16 +53,18 @@
package hierarchy inside the Android package used to have an
org.mozilla.{fennec,firefox,firefox_beta} subtree *and* an
org.mozilla.gecko subtree; it now only has org.mozilla.gecko. -->
<activity android:name="@MOZ_ANDROID_BROWSER_INTENT_CLASS@"
android:label="@string/moz_app_displayname"
android:taskAffinity="@ANDROID_PACKAGE_NAME@.BROWSER"
android:alwaysRetainTaskState="true"
android:configChanges="keyboard|keyboardHidden|mcc|mnc|orientation|screenSize|locale|layoutDirection|smallestScreenSize|screenLayout"
+ android:resizeableActivity="true"
+ android:supportsPictureInPicture="true"
android:windowSoftInputMode="stateUnspecified|adjustResize"
android:launchMode="singleTask"
android:exported="true"
android:theme="@style/Gecko.App" />
<!-- Bug 1256615 / Bug 1268455: We published an .App alias and we need to maintain it
forever. If we don't, home screen shortcuts will disappear because the intent
filter details change. -->
--- a/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
+++ b/mobile/android/base/java/org/mozilla/gecko/BrowserApp.java
@@ -11,18 +11,20 @@ import android.app.Activity;
import android.app.AlertDialog;
import android.app.DownloadManager;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
+import android.content.pm.ActivityInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
+import android.content.res.Configuration;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.nfc.NdefMessage;
@@ -105,16 +107,17 @@ import org.mozilla.gecko.home.HomePanels
import org.mozilla.gecko.home.HomeScreen;
import org.mozilla.gecko.home.SearchEngine;
import org.mozilla.gecko.icons.Icons;
import org.mozilla.gecko.icons.IconsHelper;
import org.mozilla.gecko.icons.decoders.FaviconDecoder;
import org.mozilla.gecko.icons.decoders.IconDirectoryEntry;
import org.mozilla.gecko.icons.decoders.LoadFaviconResult;
import org.mozilla.gecko.lwt.LightweightTheme;
+import org.mozilla.gecko.media.PictureInPictureController;
import org.mozilla.gecko.media.VideoPlayer;
import org.mozilla.gecko.menu.GeckoMenu;
import org.mozilla.gecko.menu.GeckoMenuItem;
import org.mozilla.gecko.mma.MmaDelegate;
import org.mozilla.gecko.mozglue.SafeIntent;
import org.mozilla.gecko.notifications.NotificationHelper;
import org.mozilla.gecko.overlays.ui.ShareDialog;
import org.mozilla.gecko.permissions.Permissions;
@@ -232,16 +235,17 @@ public class BrowserApp extends GeckoApp
private BrowserSearch mBrowserSearch;
private View mBrowserSearchContainer;
public ViewGroup mBrowserChrome;
public ViewFlipper mActionBarFlipper;
public ActionModeCompatView mActionBar;
private VideoPlayer mVideoPlayer;
+ private PictureInPictureController mPipController;
private BrowserToolbar mBrowserToolbar;
private View doorhangerOverlay;
// We can't name the TabStrip class because it's not included on API 9.
private TabStripInterface mTabStrip;
private AnimatedProgressBar mProgressView;
private HomeScreen mHomeScreen;
private TabsPanel mTabsPanel;
@@ -264,16 +268,17 @@ public class BrowserApp extends GeckoApp
// When the static action bar is shown, only the real toolbar chrome should be
// shown when the toolbar is visible. Causing the toolbar animator to also
// show the snapshot causes the content to shift under the users finger.
// See: Bug 1358554
private boolean mShowingToolbarChromeForActionBar;
private SafeIntent safeStartingIntent;
+ private Intent startingIntentAfterPip;
private boolean isInAutomation;
private static class MenuItemInfo implements Parcelable {
public int id;
public String label;
public boolean checkable;
public boolean checked;
public boolean enabled = true;
@@ -859,16 +864,17 @@ public class BrowserApp extends GeckoApp
mBrowserSearchContainer = findViewById(R.id.search_container);
mBrowserSearch = (BrowserSearch) getSupportFragmentManager().findFragmentByTag(BROWSER_SEARCH_TAG);
if (mBrowserSearch == null) {
mBrowserSearch = BrowserSearch.newInstance();
mBrowserSearch.setUserVisibleHint(false);
}
setBrowserToolbarListeners();
+ mPipController = new PictureInPictureController(this);
mFindInPageBar = (FindInPageBar) findViewById(R.id.find_in_page);
mMediaCastingBar = (MediaCastingBar) findViewById(R.id.media_casting);
doorhangerOverlay = findViewById(R.id.doorhanger_overlay);
EventDispatcher.getInstance().registerGeckoThreadListener(this,
"Search:Keyword",
@@ -1179,16 +1185,48 @@ public class BrowserApp extends GeckoApp
}
for (BrowserAppDelegate delegate : delegates) {
delegate.onPause(this);
}
}
@Override
+ protected void onUserLeaveHint() {
+ super.onUserLeaveHint();
+ try {
+ mPipController.tryEnteringPictureInPictureMode();
+ } catch (IllegalStateException exception) {
+ Log.e(LOGTAG, "Cannot enter in Picture In Picture mode:\n" + exception.getMessage());
+ }
+ }
+
+ @Override
+ public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, Configuration newConfig) {
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig);
+
+ if (!isInPictureInPictureMode) {
+ mPipController.cleanResources();
+
+ // User clicked a new link to be opened in Firefox.
+ // We returned from Picture-in-picture mode and now must try to open that link.
+ if (startingIntentAfterPip != null) {
+ getApplication().startActivity(startingIntentAfterPip);
+ startingIntentAfterPip = null;
+ } else {
+ // After returning from Picture-in-picture mode the video will still be playing
+ // in fullscreen. But now we have the status bar showing.
+ // Call setFullscreen(..) to hide it and offer the same fullscreen video experience
+ // that the user had before entering in Picture-in-picture mode.
+ ActivityUtils.setFullScreen(this, true);
+ }
+ }
+ }
+
+ @Override
public void onRestart() {
super.onRestart();
if (mIsAbortingAppLaunch) {
return;
}
for (final BrowserAppDelegate delegate : delegates) {
delegate.onRestart(this);
@@ -1234,16 +1272,22 @@ public class BrowserApp extends GeckoApp
@Override
public void onStop() {
super.onStop();
if (mIsAbortingAppLaunch) {
return;
}
+ if (mPipController.isInPipMode()) {
+ // If screen is locked we should exit PictureInPicture mode
+ moveTaskToBack(true);
+ mPipController.cleanResources();
+ }
+
// We only show the guest mode notification when our activity is in the foreground.
GuestSession.hideNotification(this);
for (final BrowserAppDelegate delegate : delegates) {
delegate.onStop(this);
}
onAfterStop();
@@ -4109,16 +4153,43 @@ public class BrowserApp extends GeckoApp
}
/*
* If the app has been launched a certain number of times, and we haven't asked for feedback before,
* open a new tab with about:feedback when launching the app from the icon shortcut.
*/
@Override
protected void onNewIntent(Intent externalIntent) {
+
+ // Currently there is no way to exit PictureInPicture mode programmatically
+ // https://issuetracker.google.com/issues/37254459
+ // but because we are "singleTask" we will receive the Intents to open a new link.
+ // When this happens, the new Intent will trigger `onPictureInPictureModeChanged(..)`
+ //
+ // Whenever the user presses a new link to be opened in Firefox we must
+ // cache the received Intent, wait for PictureInPicture mode to end and then act on that Intent.
+ if (mPipController.isInPipMode()) {
+
+ startingIntentAfterPip = externalIntent;
+
+ // Pattern used to exit MultiWindow - https://stackoverflow.com/a/43288507/4249825
+ // also works for us.
+ // Without this the old tab would continue playing media.
+ moveTaskToBack(true);
+ startingIntentAfterPip.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
+
+ // To enter PictureInPicture mode the video must be playing in fullscreen, which also
+ // means the orientation will be changed to Landscape.
+ // If when pressing the new link the device is actually in Portrait we will force
+ // the activity to enter in Portrait before opening the new tab.
+ setRequestedOrientationForCurrentActivity(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);
+
+ return;
+ }
+
final SafeIntent intent = new SafeIntent(externalIntent);
String action = intent.getAction();
final boolean isViewAction = Intent.ACTION_VIEW.equals(action);
final boolean isBookmarkAction = GeckoApp.ACTION_HOMESCREEN_SHORTCUT.equals(action);
final boolean isTabQueueAction = TabQueueHelper.LOAD_URLS_ACTION.equals(action);
final boolean isViewMultipleAction = ACTION_VIEW_MULTIPLE.equals(action);
--- a/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaControlAgent.java
+++ b/mobile/android/base/java/org/mozilla/gecko/media/GeckoMediaControlAgent.java
@@ -24,23 +24,25 @@ import android.os.Build;
import android.os.Bundle;
import android.support.annotation.CheckResult;
import android.support.annotation.NonNull;
import android.support.annotation.VisibleForTesting;
import android.support.v4.app.NotificationManagerCompat;
import android.util.Log;
import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.EventDispatcher;
import org.mozilla.gecko.GeckoAppShell;
import org.mozilla.gecko.GeckoApplication;
import org.mozilla.gecko.IntentHelper;
import org.mozilla.gecko.PrefsHelper;
import org.mozilla.gecko.R;
import org.mozilla.gecko.Tab;
import org.mozilla.gecko.annotation.RobocopTarget;
+import org.mozilla.gecko.util.GeckoBundle;
import org.mozilla.gecko.util.ThreadUtils;
import static org.mozilla.gecko.BuildConfig.DEBUG;
public class GeckoMediaControlAgent {
private static final String LOGTAG = "GeckoMediaControlAgent";
@SuppressLint("StaticFieldLeak")
@@ -79,17 +81,17 @@ public class GeckoMediaControlAgent {
private int minCoverSize;
private int coverSize;
private Notification currentNotification;
/**
* Internal state of MediaControlService, to indicate it is playing media, or paused...etc.
*/
- private State mMediaState = State.STOPPED;
+ private static State sMediaState = State.STOPPED;
protected enum State {
PLAYING,
PAUSED,
STOPPED
}
@RobocopTarget
@@ -150,24 +152,24 @@ public class GeckoMediaControlAgent {
mPrefsObserver = new PrefsHelper.PrefHandlerBase() {
@Override
public void prefValue(String pref, boolean value) {
if (pref.equals(MEDIA_CONTROL_PREF)) {
mIsMediaControlPrefOn = value;
// If media is playing, we just need to create or remove
// the media control interface.
- if (mMediaState.equals(State.PLAYING)) {
+ if (sMediaState.equals(State.PLAYING)) {
setState(mIsMediaControlPrefOn ? State.PLAYING : State.STOPPED);
}
// If turn off pref during pausing, except removing media
// interface, we also need to stop the service and notify
// gecko about that.
- if (mMediaState.equals(State.PAUSED) &&
+ if (sMediaState.equals(State.PAUSED) &&
!mIsMediaControlPrefOn) {
handleAction(ACTION_STOP);
}
}
}
};
PrefsHelper.addObserver(mPrefs, mPrefsObserver);
}
@@ -233,45 +235,49 @@ public class GeckoMediaControlAgent {
AudioFocusAgent.getInstance().clearActiveMediaTab();
}
});
mSession.setActive(true);
return true;
}
private void notifyObservers(String topic, String data) {
+ final GeckoBundle newStatusBundle = new GeckoBundle(1);
+ newStatusBundle.putString(topic, data);
+ EventDispatcher.getInstance().dispatch("MediaControlService:MediaPlayingStatus", newStatusBundle);
+
GeckoAppShell.notifyObservers(topic, data);
}
private boolean isNeedToRemoveControlInterface(State state) {
return state.equals(State.STOPPED);
}
private void setState(State newState) {
- mMediaState = newState;
- setMediaStateForTab(mMediaState.equals(State.PLAYING));
+ sMediaState = newState;
+ setMediaStateForTab(sMediaState.equals(State.PLAYING));
onStateChanged();
}
private void setMediaStateForTab(boolean isTabPlaying) {
final Tab tab = AudioFocusAgent.getInstance().getActiveMediaTab();
if (tab == null) {
return;
}
tab.setIsMediaPlaying(isTabPlaying);
}
private void onStateChanged() {
if (!mInitialized) {
return;
}
- Log.d(LOGTAG, "onStateChanged, state = " + mMediaState);
+ Log.d(LOGTAG, "onStateChanged, state = " + sMediaState);
- if (isNeedToRemoveControlInterface(mMediaState)) {
+ if (isNeedToRemoveControlInterface(sMediaState)) {
stopForegroundService();
NotificationManagerCompat.from(mContext).cancel(R.id.mediaControlNotification);
release();
return;
}
if (!mIsMediaControlPrefOn) {
return;
@@ -286,34 +292,34 @@ public class GeckoMediaControlAgent {
ThreadUtils.postToBackgroundThread(new Runnable() {
@Override
public void run() {
updateNotification(tab);
}
});
}
- private boolean isMediaPlaying() {
- return mMediaState.equals(State.PLAYING);
+ /* package */ static boolean isMediaPlaying() {
+ return sMediaState.equals(State.PLAYING);
}
public void handleAction(String action) {
if (action == null) {
return;
}
if (!mInitialized && action.equals(ACTION_TAB_STATE_PLAYING)) {
initialize();
}
if (!mInitialized) {
return;
}
- Log.d(LOGTAG, "HandleAction, action = " + action + ", mediaState = " + mMediaState);
+ Log.d(LOGTAG, "HandleAction, action = " + action + ", mediaState = " + sMediaState);
switch (action) {
case ACTION_RESUME :
mController.getTransportControls().play();
break;
case ACTION_PAUSE :
mController.getTransportControls().pause();
break;
case ACTION_STOP :
@@ -411,17 +417,17 @@ public class GeckoMediaControlAgent {
startForegroundService();
} else {
stopForegroundService();
NotificationManagerCompat.from(mContext).notify(R.id.mediaControlNotification, getCurrentNotification());
}
}
private Notification.Action createNotificationAction() {
- final Intent intent = createIntentUponState(mMediaState);
+ final Intent intent = createIntentUponState(sMediaState);
boolean isPlayAction = intent.getAction().equals(ACTION_RESUME);
int icon = isPlayAction ? R.drawable.ic_media_play : R.drawable.ic_media_pause;
String title = mContext.getString(isPlayAction ? R.string.media_play : R.string.media_pause);
final PendingIntent pendingIntent = PendingIntent.getService(mContext, 1, intent, 0);
//noinspection deprecation - The new constructor is only for API > 23
@@ -502,17 +508,17 @@ public class GeckoMediaControlAgent {
private void release() {
if (!mInitialized) {
return;
}
mInitialized = false;
Log.d(LOGTAG, "release");
- if (!mMediaState.equals(State.STOPPED)) {
+ if (!sMediaState.equals(State.STOPPED)) {
setState(State.STOPPED);
}
PrefsHelper.removeObserver(mPrefsObserver);
mHeadSetStateReceiver.unregisterReceiver(mContext);
mSession.release();
}
private class HeadSetStateReceiver extends BroadcastReceiver {
new file mode 100644
--- /dev/null
+++ b/mobile/android/base/java/org/mozilla/gecko/media/PictureInPictureController.java
@@ -0,0 +1,210 @@
+package org.mozilla.gecko.media;
+
+import android.accessibilityservice.AccessibilityServiceInfo;
+import android.annotation.SuppressLint;
+import android.app.Activity;
+import android.app.PendingIntent;
+import android.app.PictureInPictureParams;
+import android.app.RemoteAction;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.graphics.drawable.Icon;
+import android.support.annotation.NonNull;
+import android.util.Log;
+import android.view.accessibility.AccessibilityManager;
+
+import org.mozilla.gecko.AppConstants;
+import org.mozilla.gecko.BuildConfig;
+import org.mozilla.gecko.EventDispatcher;
+import org.mozilla.gecko.R;
+import org.mozilla.gecko.util.ActivityUtils;
+import org.mozilla.gecko.util.BundleEventListener;
+import org.mozilla.gecko.util.EventCallback;
+import org.mozilla.gecko.util.GeckoBundle;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@SuppressLint("NewApi")
+public class PictureInPictureController implements BundleEventListener {
+ public static final String LOGTAG = "PictureInPictureController";
+
+ private final Activity pipActivity;
+ private boolean isInPipMode;
+
+ public PictureInPictureController(Activity activity) {
+ pipActivity = activity;
+ }
+
+ /**
+ * If the feature is supported and media is playing in fullscreen will try to activate
+ * Picture In Picture mode for the current activity.
+ * @throws IllegalStateException if entering Picture In Picture mode was not possible.
+ */
+ public void tryEnteringPictureInPictureMode() throws IllegalStateException {
+ if (shouldTryPipMode()) {
+ EventDispatcher.getInstance().registerUiThreadListener(this, "MediaControlService:MediaPlayingStatus");
+ pipActivity.enterPictureInPictureMode(getPipParams(isMediaPlaying()));
+ isInPipMode = true;
+ }
+ }
+
+ public void cleanResources() {
+ if (isInPipMode) {
+ EventDispatcher.getInstance().unregisterUiThreadListener(this, "MediaControlService:MediaPlayingStatus");
+ isInPipMode = false;
+ }
+ }
+
+ public boolean isInPipMode() {
+ return isInPipMode;
+ }
+
+ private boolean shouldTryPipMode() {
+ if (!AppConstants.Versions.feature26Plus) {
+ logDebugMessage("Picture In Picture is only available on Oreo+");
+ return false;
+ }
+
+ if (pipActivity.isChangingConfigurations()) {
+ logDebugMessage("Activity is being restarted");
+ return false;
+ }
+
+ if (pipActivity.isFinishing()) {
+ logDebugMessage("Activity is finishing");
+ return false;
+ }
+
+ // PIP might be disabled on devices that have low RAM
+ if (!pipActivity.getPackageManager().hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)) {
+ logDebugMessage("Picture In Picture mode not supported");
+ return false;
+ }
+
+ if (isScreenReaderActiveAndTroublesome()) {
+ logDebugMessage("Picture In Picture mode not supported when screen reader is active");
+ return false;
+ }
+
+ if (!ActivityUtils.isFullScreen(pipActivity)) {
+ logDebugMessage("Activity is not in fullscreen");
+ return false;
+ }
+
+ if (!isMediaPlaying()) {
+ logDebugMessage("Will not enter Picture in Picture mode if no media is playing");
+ return false;
+ }
+
+ if (pipActivity.isInPictureInPictureMode()) {
+ logDebugMessage("Activity is already in Picture In Picture " +
+ "or Picture In Picture mode is \"Not allowed\" by the user");
+ return false;
+ }
+
+ return true;
+ }
+
+ private void updatePictureInPictureActions(@NonNull final PictureInPictureParams params) {
+ pipActivity.setPictureInPictureParams(params);
+ }
+
+ private PictureInPictureParams getPipParams(final boolean isMediaPlaying) {
+ final List<RemoteAction> actions = new ArrayList<>(1);
+ final PictureInPictureParams.Builder builder = new PictureInPictureParams.Builder();
+
+ actions.add(isMediaPlaying ? getPauseRemoteAction() : getPlayRemoteAction());
+ builder.setActions(actions);
+
+ return builder.build();
+ }
+
+ private RemoteAction getPauseRemoteAction() {
+ final String actionTitle = pipActivity.getString(R.string.pip_pause_button_title);
+ final String actionDescription = pipActivity.getString(R.string.pip_pause_button_description);
+
+ return new RemoteAction(getPauseIcon(), actionTitle, actionDescription, getIntentToPauseMedia());
+ }
+
+ private RemoteAction getPlayRemoteAction() {
+ final String actionTitle = pipActivity.getString(R.string.pip_play_button_title);
+ final String actionDescription = pipActivity.getString(R.string.pip_play_button_description);
+
+ return new RemoteAction(getPlayIcon(), actionTitle, actionDescription, getIntentToResumeMedia());
+ }
+
+ private PendingIntent getIntentToPauseMedia() {
+ return PendingIntent.getService(pipActivity, 0,
+ getMediaControllerIntentForAction(GeckoMediaControlAgent.ACTION_PAUSE), 0);
+ }
+
+ private PendingIntent getIntentToResumeMedia() {
+ return PendingIntent.getService(pipActivity, 0,
+ getMediaControllerIntentForAction(GeckoMediaControlAgent.ACTION_RESUME), 0);
+ }
+
+ private Icon getPauseIcon() {
+ return Icon.createWithResource(pipActivity, R.drawable.ic_media_pause);
+ }
+
+ private Icon getPlayIcon() {
+ return Icon.createWithResource(pipActivity, R.drawable.ic_media_play);
+ }
+
+ private Intent getMediaControllerIntentForAction(@NonNull final String action) {
+ return new Intent(action, null, pipActivity, MediaControlService.class);
+ }
+
+ private boolean isMediaPlaying() {
+ return GeckoMediaControlAgent.isMediaPlaying();
+ }
+
+ /**
+ * Trying to enter Picture In Picture mode on a Samsung device while an accessibility service
+ * that provides spoken feedback would result in an IllegalStateException.
+ */
+ private boolean isScreenReaderActiveAndTroublesome() {
+ final String affectedManufacturer = "samsung";
+
+ final AccessibilityManager am =
+ (AccessibilityManager) pipActivity.getSystemService(Context.ACCESSIBILITY_SERVICE);
+ final List<AccessibilityServiceInfo> enabledScreenReaderServices =
+ am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_SPOKEN);
+ final String deviceManufacturer = android.os.Build.MANUFACTURER.toLowerCase();
+
+ final boolean isScreenReaderServiceActive = !enabledScreenReaderServices.isEmpty();
+ final boolean isDeviceManufacturerAffected = affectedManufacturer.equals(deviceManufacturer);
+
+ return isScreenReaderServiceActive && isDeviceManufacturerAffected;
+ }
+
+ private void logDebugMessage(@NonNull final String message) {
+ if (BuildConfig.DEBUG) {
+ Log.d(LOGTAG, message);
+ }
+ }
+
+ @Override
+ public void handleMessage(String event, GeckoBundle message, EventCallback callback) {
+ String newMediaStatus = message.getString("mediaControl");
+ if (newMediaStatus == null) {
+ Log.w(LOGTAG, "Can't extract new media status");
+ return;
+ }
+ switch (newMediaStatus) {
+ case "resumeMedia":
+ updatePictureInPictureActions(getPipParams(true));
+ break;
+ case "mediaControlPaused":
+ updatePictureInPictureActions(getPipParams(false));
+ break;
+ case "mediaControlStopped":
+ updatePictureInPictureActions(getPipParams(false));
+ break;
+ default:
+ Log.w(LOGTAG, String.format("Unknown new media status: %s", newMediaStatus));
+ }
+ }
+}
--- a/mobile/android/base/locales/en-US/android_strings.dtd
+++ b/mobile/android/base/locales/en-US/android_strings.dtd
@@ -880,8 +880,15 @@ is simply hidden from the Activity Strea
<!ENTITY pwa_add_to_launcher_confirm "+ Add to Home Screen">
<!-- LOCALIZATION NOTE (pwa_add_to_launcher_badge2): Used as label in the page actions dropdown list,
displayed when there are more than 3 actions available for a page.
See also https://bug1409261.bmoattachments.org/attachment.cgi?id=8919897 -->
<!ENTITY pwa_add_to_launcher_badge2 "Add to Home Screen">
<!ENTITY pwa_continue_to_website "Continue to Website">
<!ENTITY pwa_onboarding_sumo "You can easily add this website to your Home screen to have instant access and browse faster with an app-like experience.">
+
+<!-- Used by accessibility services to identify the play/pause buttons shown in the
+Picture-in-picture mini window -->
+<!ENTITY pip_play_button_title "Play">
+<!ENTITY pip_play_button_description "Resume playing">
+<!ENTITY pip_pause_button_title "Pause">
+<!ENTITY pip_pause_button_description "Pause playing">
--- a/mobile/android/base/strings.xml.in
+++ b/mobile/android/base/strings.xml.in
@@ -636,9 +636,14 @@
<string name="private_tab_learn_more">&private_tab_learn_more;</string>
<string name="fullscreen_warning">&fullscreen_warning;</string>
<string name="pwa_add_to_launcher_confirm">&pwa_add_to_launcher_confirm;</string>
<string name="pwa_add_to_launcher_badge">&pwa_add_to_launcher_badge2;</string>
<string name="pwa_onboarding_sumo">&pwa_onboarding_sumo;</string>
<string name="pwa_continue_to_website">&pwa_continue_to_website;</string>
+
+ <string name="pip_play_button_title">&pip_play_button_title;</string>
+ <string name="pip_play_button_description">&pip_play_button_description;</string>
+ <string name="pip_pause_button_title">&pip_pause_button_title;</string>
+ <string name="pip_pause_button_description">&pip_pause_button_description;</string>
</resources>