// 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.readaloud;
import static org.chromium.chrome.modules.readaloud.PlaybackListener.State.PAUSED;
import static org.chromium.chrome.modules.readaloud.PlaybackListener.State.PLAYING;
import static org.chromium.chrome.modules.readaloud.PlaybackListener.State.STOPPED;
import android.app.Activity;
import android.content.Intent;
import android.os.Handler;
import android.os.Looper;
import android.util.LruCache;
import android.view.WindowManager;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import com.google.common.hash.Hashing;
import org.chromium.base.ActivityState;
import org.chromium.base.ApplicationState;
import org.chromium.base.ApplicationStatus;
import org.chromium.base.Callback;
import org.chromium.base.Log;
import org.chromium.base.ObserverList;
import org.chromium.base.Promise;
import org.chromium.base.ResettersForTesting;
import org.chromium.base.TraceEvent;
import org.chromium.base.UserData;
import org.chromium.base.supplier.ObservableSupplier;
import org.chromium.base.supplier.ObservableSupplierImpl;
import org.chromium.base.supplier.OneShotCallback;
import org.chromium.base.task.PostTask;
import org.chromium.base.task.TaskTraits;
import org.chromium.chrome.browser.browser_controls.BottomControlsStacker;
import org.chromium.chrome.browser.device.DeviceConditions;
import org.chromium.chrome.browser.language.AppLocaleUtils;
import org.chromium.chrome.browser.layouts.LayoutManager;
import org.chromium.chrome.browser.lifecycle.ActivityLifecycleDispatcher;
import org.chromium.chrome.browser.lifecycle.OnUserLeaveHintObserver;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.chrome.browser.readaloud.ReadAloudMetrics.ReasonForStoppingPlayback;
import org.chromium.chrome.browser.readaloud.exceptions.ReadAloudUnsupportedException;
import org.chromium.chrome.browser.tab.Tab;
import org.chromium.chrome.browser.tab.TabSelectionType;
import org.chromium.chrome.browser.tabmodel.TabModel;
import org.chromium.chrome.browser.translate.TranslateBridge;
import org.chromium.chrome.browser.translate.TranslationObserver;
import org.chromium.chrome.browser.user_education.UserEducationHelper;
import org.chromium.chrome.modules.readaloud.Playback;
import org.chromium.chrome.modules.readaloud.PlaybackArgs;
import org.chromium.chrome.modules.readaloud.PlaybackArgs.PlaybackVoice;
import org.chromium.chrome.modules.readaloud.PlaybackListener;
import org.chromium.chrome.modules.readaloud.Player;
import org.chromium.chrome.modules.readaloud.ReadAloudPlaybackHooks;
import org.chromium.chrome.modules.readaloud.ReadAloudPlaybackHooksProvider;
import org.chromium.chrome.modules.readaloud.contentjs.Extractor;
import org.chromium.chrome.modules.readaloud.contentjs.Highlighter;
import org.chromium.chrome.modules.readaloud.contentjs.Highlighter.Mode;
import org.chromium.components.browser_ui.bottomsheet.BottomSheetController;
import org.chromium.components.embedder_support.util.UrlConstants;
import org.chromium.components.prefs.PrefService;
import org.chromium.components.user_prefs.UserPrefs;
import org.chromium.content_public.browser.GlobalRenderFrameHostId;
import org.chromium.content_public.browser.WebContents;
import org.chromium.net.ConnectionType;
import org.chromium.net.NetworkChangeNotifier;
import org.chromium.ui.InsetObserver;
import org.chromium.ui.base.ActivityWindowAndroid;
import org.chromium.ui.base.WindowAndroid;
import org.chromium.url.GURL;
import java.time.Duration;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
/**
* The main entrypoint component for Read Aloud feature. It's responsible for checking its
* availability and triggering playback. Only instantiate after native is initialized.
*/
public class ReadAloudController
implements Player.Observer,
Player.Delegate,
PlaybackListener,
NetworkChangeNotifier.ConnectionTypeObserver,
ApplicationStatus.ActivityStateListener,
ApplicationStatus.ApplicationStateListener,
InsetObserver.WindowInsetObserver,
OnUserLeaveHintObserver {
private static final String TAG = "ReadAloudController";
private static final Class<RestoreState> USER_DATA_KEY = RestoreState.class;
// There may be multiple ReadAloudController instances created by different Chrome activities
// (like CCT). They are kept here so they can signal each other to release playback.
private static final HashSet<ReadAloudController> sInstances = new HashSet<>();
private final Activity mActivity;
private final ObservableSupplier<Profile> mProfileSupplier;
private final ObserverList<Runnable> mReadabilityUpdateObserverList = new ObserverList<>();
// Delay added to readability check that should run it after largest contentful paint for >85%
// of users http://uma/p/chrome/timeline_v2?sid=c975abf9022aac7b36bf28285f068dd6
private static final int READABILITY_DELAY = 3000;
private static final int MAX_URL_ENTRIES = 300;
private static final LruCache<Integer, ReadabilityInfo> sReadabilityInfoMap =
new LruCache<>(MAX_URL_ENTRIES);
private final HashSet<Integer> mPendingRequests = new HashSet<>();
private final TabModel mTabModel;
private final TabModel mIncognitoTabModel;
@Nullable private Player mPlayerCoordinator;
private final ObservableSupplier<LayoutManager> mLayoutManagerSupplier;
private TapToSeekSelectionManager mTapToSeekSelectionManager;
private final UserEducationHelper mUserEducationHelper;
private TabModelTabObserver mTabObserver;
private TabModelTabObserver mIncognitoTabObserver;
private boolean mPausedForIncognito;
private final BottomSheetController mBottomSheetController;
private final BottomControlsStacker mBottomControlsStacker;
private final ActivityLifecycleDispatcher mActivityLifecycleDispatcher;
private ReadAloudReadabilityHooks mReadabilityHooks;
@Nullable private static ReadAloudReadabilityHooks sReadabilityHooksForTesting;
@Nullable private ReadAloudPlaybackHooks mPlaybackHooks;
@Nullable private static ReadAloudPlaybackHooks sPlaybackHooksForTesting;
@Nullable private Highlighter mHighlighter;
@Nullable private Highlighter.Config mHighlighterConfig;
@Nullable private Extractor mExtractor;
// Information tied to a playback. When playback is reset it should be set to null together
// with mActivePlaybackTabSupplier's value and mGlobalRenderFrameId
@Nullable private Playback mPlayback;
private ObservableSupplierImpl<Tab> mActivePlaybackTabSupplier;
@Nullable private GURL mCurrentlyPlayingGurl;
@Nullable private GlobalRenderFrameHostId mGlobalRenderFrameId;
// Current tab playback data, or null if there is no playback.
@Nullable private PlaybackData mCurrentPlaybackData;
private long mDateModified;
// Playback for voice previews
@Nullable private Playback mVoicePreviewPlayback;
private boolean mOnUserLeaveHint;
private boolean mRestoringPlayer;
private boolean mIsDestroyed;
private boolean mIsScreenOnAndUnlocked = true;
private boolean mKeepScreenOnFlagIsSet;
/**
* ReadAloud entrypoint defined in readaloud/enums.xml.
*
* <p>Do not reorder or remove items, only add new items before NUM_ENTRIES.
*/
@IntDef({Entrypoint.OVERFLOW_MENU, Entrypoint.MAGIC_TOOLBAR, Entrypoint.RESTORED_PLAYBACK})
public @interface Entrypoint {
int OVERFLOW_MENU = 0;
int MAGIC_TOOLBAR = 1;
int RESTORED_PLAYBACK = 2;
// Be sure to also update enums.xml when updating these values.
int NUM_ENTRIES = 3;
}
/** Clock to use so we can mock time in tests. */
public interface Clock {
long currentTimeMillis();
}
private static Clock sClock = System::currentTimeMillis;
private static final long HOUR_TO_MS = Duration.ofHours(1).toMillis();
static void setClockForTesting(Clock clock) {
var oldValue = sClock;
sClock = clock;
ResettersForTesting.register(() -> sClock = oldValue);
}
private class ReadabilityInfo {
private final boolean mIsReadable;
private final long mResponseTimestamp;
private final boolean mTimepointsSupported;
/**
* Constructor.
*
* @param isReadable Is page readable.
* @param responseTimestamp Timestamp when readability request responded.
* @param timepointsSupported Whether or not timepoints are supported (needed for
* highlighting).
*/
ReadabilityInfo(boolean isReadable, long responseTimestamp, boolean timepointsSupported) {
mIsReadable = isReadable;
mResponseTimestamp = responseTimestamp;
mTimepointsSupported = timepointsSupported;
}
boolean isReadable() {
return mIsReadable;
}
long getResponseTime() {
return mResponseTimestamp;
}
boolean getTimepointsSupported() {
return mTimepointsSupported;
}
}
// Information about a tab playback necessary for resuming later. Does not
// include language or voice which should come from current tab state or
// settings respectively.
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public class RestoreState implements UserData {
// Tab to play.
private final Tab mTab;
// Paragraph index to resume from.
private final int mParagraphIndex;
// Optional - position within the paragraph to resume from.
private final long mOffsetNanos;
// True if audio should start playing immediately when this state is restored.
private final boolean mPlaying;
// Value of dateModified tag or 0
private final long mDateModified;
private final PlaybackData mData;
/**
* Constructor.
*
* @param tab Tab to play.
* @param data Current PlaybackData which may be null if playback hasn't started yet.
*/
RestoreState(Tab tab, @Nullable PlaybackData data, long dateModified) {
this(
tab,
data,
/* useOffsetInParagraph= */ true,
/* shouldPlayOverride= */ null,
dateModified);
}
/**
* Constructor.
*
* @param tab Tab to play.
* @param data Current PlaybackData which may be null if playback hasn't started yet.
*/
RestoreState(
Tab tab,
@Nullable PlaybackData data,
boolean useOffsetInParagraph,
@Nullable Boolean shouldPlayOverride,
long dateModified) {
assert !GURL.isEmptyOrInvalid(tab.getUrl());
mTab = tab;
mData = data;
mDateModified = dateModified;
if (data == null) {
mParagraphIndex = 0;
mOffsetNanos = 0L;
} else {
mParagraphIndex = data.paragraphIndex();
mOffsetNanos = data.positionInParagraphNanos();
}
if (shouldPlayOverride != null) {
mPlaying = shouldPlayOverride;
} else {
mPlaying = data == null ? true : data.state() != PAUSED && data.state() != STOPPED;
}
}
Tab getTab() {
return mTab;
}
long getDateModified() {
return mDateModified;
}
@Nullable
PlaybackData getPlaybackData() {
return mData;
}
/** Apply the saved playback state. */
void restore() {
if (GURL.isEmptyOrInvalid(mTab.getUrl())) {
ReadAloudMetrics.recordEmptyURLPlayback(
Entrypoint.RESTORED_PLAYBACK, Entrypoint.NUM_ENTRIES);
assert false;
return;
}
maybeInitializePlaybackHooks();
createTabPlayback(mTab, mDateModified, Entrypoint.RESTORED_PLAYBACK)
.then(
playback -> {
if (mPlaying) {
mPlayerCoordinator.playbackReady(playback, PLAYING);
playback.play();
} else {
mPlayerCoordinator.playbackReady(playback, PAUSED);
}
if (mParagraphIndex != 0 || mOffsetNanos != 0) {
playback.seekToParagraph(
mParagraphIndex, /* offsetNanos= */ mOffsetNanos);
}
},
exception -> {
Log.d(
TAG,
"Failed to restore playback state: %s",
exception.getMessage());
});
}
}
// State of playback that was interrupted by a voice preview and should be
// restored when closing the voice menu.
@Nullable private RestoreState mStateToRestoreOnVoiceMenuClose;
// State of playback that was interrupted by backgrounding Chrome.
@Nullable private RestoreState mStateToRestoreOnBringingToForeground;
// Whether or not to highlight the page. Change will only have effect if
// isHighlightingSupported() returns true.
private final ObservableSupplierImpl<Boolean> mHighlightingEnabled;
// Voices to show in voice selection menu.
private final ObservableSupplierImpl<List<PlaybackVoice>> mCurrentLanguageVoices;
// Selected voice ID.
private final ObservableSupplierImpl<String> mSelectedVoiceId;
private final ActivityWindowAndroid mActivityWindowAndroid;
/**
* Wrapper for TranslationObserver that keeps track of the tab it is observing and the pointer
* to the underlying native observer so that callers don't need to manage them.
*/
private static class TranslationObserverImpl implements TranslationObserver {
private Tab mTab;
private long mHandle;
private WebContents mWebContents;
void observeTab(Tab tab) {
stopObservingTab(mTab);
// A tab's WebContents can change and we'll only find out later, so keep track of the
// WebContents we registered the observer on.
WebContents webContents = tab.getWebContents();
if (webContents == null || webContents.isDestroyed()) {
return;
}
mWebContents = webContents;
mHandle = TranslateBridge.addTranslationObserver(mWebContents, this);
mTab = tab;
}
// If `tab` isn't null, only stop observing if it matches the tab being observed.
void stopObservingTab(Tab tab) {
if (tab != null && mTab != tab) {
return;
}
if (mWebContents != null && !mWebContents.isDestroyed() && mHandle != 0L) {
TranslateBridge.removeTranslationObserver(mWebContents, mHandle);
}
mTab = null;
mHandle = 0L;
mWebContents = null;
}
}
private final TranslationObserverImpl mPlayingTabTranslationObserver =
new TranslationObserverImpl() {
@Override
public void onIsPageTranslatedChanged(WebContents webContents) {
if (mActivePlaybackTabSupplier.get() != null) {
maybeStopPlayback(
mActivePlaybackTabSupplier.get(),
ReasonForStoppingPlayback.TRANSLATION_STATE_CHANGE);
}
}
@Override
public void onPageTranslated(
String sourceLanguage, String translatedLanguage, int errorCode) {
if (mActivePlaybackTabSupplier.get() != null && errorCode == 0) {
maybeStopPlayback(
mActivePlaybackTabSupplier.get(),
ReasonForStoppingPlayback.TRANSLATION_STATE_CHANGE);
}
}
};
private final TranslationObserverImpl mCurrentTabTranslationObserver =
new TranslationObserverImpl() {
@Override
public void onIsPageTranslatedChanged(WebContents webContents) {
notifyReadabilityMayHaveChanged();
}
@Override
public void onPageTranslated(
String sourceLanguage, String translatedLanguage, int errorCode) {
notifyReadabilityMayHaveChanged();
}
};
/**
* Kicks of readability check on a page load iff: the url is valid, no previous result is
* available/pending and if a request has to be sent, the necessary conditions are satisfied.
*/
private final ReadAloudReadabilityHooks.ReadabilityCallback mReadabilityCallback =
new ReadAloudReadabilityHooks.ReadabilityCallback() {
@Override
public void onSuccess(String url, boolean isReadable, boolean timepointsSupported) {
if (url.isEmpty() || url == null) {
assert false;
return;
}
Log.d(TAG, "onSuccess called for %s", url);
ReadAloudMetrics.recordIsPageReadable(isReadable);
ReadAloudMetrics.recordServerReadabilityResult(isReadable);
ReadAloudMetrics.recordIsPageReadabilitySuccessful(true);
// If destroy() was already called, stop now. Recording metrics should be okay.
if (mIsDestroyed) return;
// Register _KnownReadable trial before checking more playback conditions
if (isReadable) {
ReadAloudFeatures.activateKnownReadableTrial();
}
// isPlaybackEnabled() should only be checked if isReadable == true.
isReadable = isReadable && ReadAloudFeatures.isPlaybackEnabled();
int urlHash = urlToHash(url);
sReadabilityInfoMap.put(
urlHash,
new ReadabilityInfo(
isReadable, sClock.currentTimeMillis(), timepointsSupported));
mPendingRequests.remove(urlHash);
notifyReadabilityMayHaveChanged();
}
@Override
public void onFailure(String url, Throwable t) {
Log.d(TAG, "onFailure called for %s because %s", url, t);
ReadAloudMetrics.recordIsPageReadabilitySuccessful(false);
mPendingRequests.remove(urlToHash(url));
}
};
private final PlaybackListener mVoicePreviewPlaybackListener =
new PlaybackListener() {
@Override
public void onPlaybackDataChanged(PlaybackData data) {
if (data.state() == PlaybackListener.State.STOPPED) {
destroyVoicePreview();
}
}
};
private final Callback<Boolean> mHighlightingEnabledObserver =
this::onHighlightingEnabledChanged;
public ReadAloudController(
Activity activity,
ObservableSupplier<Profile> profileSupplier,
TabModel tabModel,
TabModel incognitoTabModel,
BottomSheetController bottomSheetController,
BottomControlsStacker bottomControlsStacker,
ObservableSupplier<LayoutManager> layoutManagerSupplier,
ActivityWindowAndroid activityWindowAndroid,
ActivityLifecycleDispatcher activityLifecycleDispatcher) {
sInstances.add(this);
ReadAloudFeatures.init();
mActivity = activity;
mProfileSupplier = profileSupplier;
new OneShotCallback<Profile>(mProfileSupplier, this::onProfileAvailable);
mTabModel = tabModel;
mIncognitoTabModel = incognitoTabModel;
mBottomSheetController = bottomSheetController;
mCurrentLanguageVoices = new ObservableSupplierImpl<>();
mSelectedVoiceId = new ObservableSupplierImpl<>();
mBottomControlsStacker = bottomControlsStacker;
mLayoutManagerSupplier = layoutManagerSupplier;
mHighlightingEnabled = new ObservableSupplierImpl<>(false);
ApplicationStatus.registerApplicationStateListener(this);
ApplicationStatus.registerStateListenerForActivity(this, mActivity);
mActivityWindowAndroid = activityWindowAndroid;
mActivityLifecycleDispatcher = activityLifecycleDispatcher;
mActivityLifecycleDispatcher.register(this);
mUserEducationHelper =
new UserEducationHelper(
activity, mProfileSupplier, new Handler(Looper.getMainLooper()));
mActivePlaybackTabSupplier = new ObservableSupplierImpl<>();
if (ReadAloudFeatures.isTapToSeekEnabled()) {
mTapToSeekSelectionManager =
new TapToSeekSelectionManager(this, mActivePlaybackTabSupplier);
}
if (NetworkChangeNotifier.isInitialized()) {
NetworkChangeNotifier.addConnectionTypeObserver(this);
}
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public void onProfileAvailable(Profile profile) {
TraceEvent.begin("ReadAloudController#onProfileAvailable");
mReadabilityHooks =
sReadabilityHooksForTesting != null
? sReadabilityHooksForTesting
: new ReadAloudReadabilityHooksImpl(mActivity, profile);
if (mReadabilityHooks.isEnabled()) {
boolean isAllowed = ReadAloudFeatures.isAllowed(profile);
ReadAloudMetrics.recordIsUserEligible(isAllowed);
if (!isAllowed) {
ReadAloudMetrics.recordIneligibilityReason(
ReadAloudFeatures.getIneligibilityReason());
}
mHighlightingEnabled.addObserver(mHighlightingEnabledObserver);
mHighlightingEnabled.set(ReadAloudPrefs.isHighlightingEnabled(getPrefService()));
ReadAloudMetrics.recordHighlightingEnabledOnStartup(mHighlightingEnabled.get());
mTabObserver =
new TabModelTabObserver(mTabModel) {
@Override
public void onLoadStarted(Tab tab, boolean toDifferentDocument) {
if (tab != null && toDifferentDocument) {
maybeHandleTabReload(tab, tab.getUrl());
maybeStopPlayback(
tab,
ReasonForStoppingPlayback.NAVIGATION_WITHIN_PLAYING_TAB);
}
}
@Override
public void onUrlUpdated(Tab tab) {
Tab currentlyPlayingTab = mActivePlaybackTabSupplier.get();
if (currentlyPlayingTab != null
&& currentlyPlayingTab.getId() == tab.getId()
&& mCurrentlyPlayingGurl != null
&& !mCurrentlyPlayingGurl.equals(tab.getUrl())) {
maybeStopPlayback(
tab,
ReasonForStoppingPlayback.NAVIGATION_WITHIN_PLAYING_TAB);
}
}
@Override
public void onActivityAttachmentChanged(
Tab tab, @Nullable WindowAndroid window) {
super.onActivityAttachmentChanged(tab, window);
if (mActivePlaybackTabSupplier.get() != null
&& mActivePlaybackTabSupplier.get().getId() == tab.getId()) {
Log.d(TAG, "Saving state");
RestoreState state =
new RestoreState(
mActivePlaybackTabSupplier.get(),
mCurrentPlaybackData,
mDateModified);
tab.getUserDataHost().setUserData(USER_DATA_KEY, state);
}
maybeStopPlayback(
tab, ReasonForStoppingPlayback.ACTIVITY_ATTACHEMENT_CHANGED);
mCurrentTabTranslationObserver.stopObservingTab(tab);
}
@Override
public void didFirstVisuallyNonEmptyPaint(Tab tab) {
if (tab != null && !GURL.isEmptyOrInvalid(tab.getUrl())) {
PostTask.postDelayedTask(
TaskTraits.UI_DEFAULT,
() -> maybeCheckReadability(tab),
READABILITY_DELAY);
}
}
@Override
public void onTabSelected(Tab tab) {
mCurrentTabTranslationObserver.stopObservingTab(null);
// This method is called when a tab is manually selected by user or
// other reason, for example opening a new tab.
// For redirects, it will be called multiple times - for the original
// url and then the destination url. Because of that we should not use
// this method to trigger readability.
super.onTabSelected(tab);
if (tab != null
&& !tab.isDestroyed()
&& !GURL.isEmptyOrInvalid(tab.getUrl())) {
if (mPausedForIncognito) {
mPausedForIncognito = false;
if (mPlayback != null) {
mPlayerCoordinator.restorePlayers();
}
}
RestoreState restored =
tab.getUserDataHost().getUserData(USER_DATA_KEY) != null
? tab.getUserDataHost().getUserData(USER_DATA_KEY)
: null;
if (restored != null
&& restored.getTab().getUrl().equals(tab.getUrl())) {
mRestoringPlayer = true;
Log.d(
TAG,
"Restore state: swapping tab from the old activity with"
+ " this one");
RestoreState updatedRestored =
new RestoreState(
tab,
restored.getPlaybackData(),
restored.getDateModified());
updatedRestored.restore();
tab.getUserDataHost().removeUserData(USER_DATA_KEY);
}
maybeAddTranslationObserver(tab);
}
}
@Override
public void willCloseTab(Tab tab) {
maybeStopPlayback(tab, ReasonForStoppingPlayback.TAB_CLOSED);
// Make sure our translation observers are removed before tab's
// WebContents is destroyed.
removeTranslationObservers(tab);
}
@Override
public void onDestroyed(Tab tab) {
// Make sure our translation observers are removed before tab's
// WebContents is destroyed.
removeTranslationObservers(tab);
}
@Override
public void onContentChanged(Tab tab) {
// Required to register the observer on navigation and reload, since it
// isn't safe to do in onPageLoadStarted().
mCurrentTabTranslationObserver.stopObservingTab(tab);
maybeAddTranslationObserver(tab);
if (tab == mActivePlaybackTabSupplier.get()) {
mPlayingTabTranslationObserver.stopObservingTab(tab);
}
}
@Override
public void webContentsWillSwap(Tab tab) {
// When restoring a tab from Recent Tabs, the tab's native WebContents
// is destroyed and replaced by a different one. We must remove the old
// WebContents' translation observers before it is destroyed.
removeTranslationObservers(tab);
}
private void maybeAddTranslationObserver(Tab tab) {
if (isURLReadAloudSupported(tab.getUrl())) {
mCurrentTabTranslationObserver.observeTab(tab);
}
}
};
mIncognitoTabObserver =
new TabModelTabObserver(mIncognitoTabModel) {
@Override
protected void onTabSelected(Tab tab) {
super.onTabSelected(tab);
if (tab == null || !tab.isIncognito()) {
return;
}
if (mPlayback != null && !mPausedForIncognito) {
mPlayback.pause();
mPlayerCoordinator.hidePlayers();
mPausedForIncognito = true;
}
}
};
InsetObserver insetObserver = mActivityWindowAndroid.getInsetObserver();
if (insetObserver != null) {
insetObserver.addObserver(this);
}
}
TraceEvent.end("ReadAloudController#onProfileAvailable");
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public void maybeCheckReadability(Tab tab) {
if (!isAvailable()) {
return;
}
if (mReadabilityHooks == null) {
return;
}
if (mProfileSupplier.get() == null || !mProfileSupplier.get().isNativeInitialized()) {
return;
}
if (tab.isShowingErrorPage()) {
return;
}
GURL url = tab.getUrl();
if (!isURLReadAloudSupported(url)) {
ReadAloudMetrics.recordIsPageReadable(false);
return;
}
String urlSpec = stripUserData(url).getSpec();
int urlSpecHash = urlToHash(urlSpec);
if (mPendingRequests.contains(urlSpecHash)) {
return;
}
ReadabilityInfo info = getReadabilityInfoIfUnexpired(urlSpecHash);
if (info != null) {
ReadAloudMetrics.recordIsPageReadable(info.isReadable());
return;
}
mPendingRequests.add(urlSpecHash);
mReadabilityHooks.isPageReadable(urlSpec, mReadabilityCallback);
}
private ReadabilityInfo getReadabilityInfoIfUnexpired(int sanitizedUrlHash) {
ReadabilityInfo info = sReadabilityInfoMap.get(sanitizedUrlHash);
if (info != null) {
Long retrievalDate = info.getResponseTime();
if (retrievalDate != null && sClock.currentTimeMillis() - retrievalDate <= HOUR_TO_MS) {
return info;
}
sReadabilityInfoMap.remove(sanitizedUrlHash);
notifyReadabilityMayHaveChanged();
}
return null;
}
/**
* Checks if URL is supported by Read Aloud before sending a readability request. Read Aloud
* won't be supported on the following URLs:
*
* <ul>
* <li>pages without an HTTP(S) scheme
* <li>myaccount.google.com and myactivity.google.com
* <li>www.google.com/...
* <li>Based on standards.google/processes/domains/domain-guidelines-by-use-case,
* www.google.com/... is reserved for Search features and content.
* </ul>
*/
public boolean isURLReadAloudSupported(GURL url) {
return !GURL.isEmptyOrInvalid(url)
&& (url.getScheme().equals(UrlConstants.HTTP_SCHEME)
|| url.getScheme().equals(UrlConstants.HTTPS_SCHEME))
&& !url.getSpec().startsWith(UrlConstants.GOOGLE_ACCOUNT_HOME_URL)
&& !url.getSpec().startsWith(UrlConstants.MY_ACTIVITY_HOME_URL)
&& !url.getSpec().startsWith(UrlConstants.GOOGLE_URL);
}
/**
* Checks if Read Aloud is supported which is true iff: user is not in the incognito mode and
* user opted into "Make searches and browsing better". If the ReadAloudInMultiWindow flag is
* disabled, this will return false if the activity is in multi window mode.
*/
public boolean isAvailable() {
return ReadAloudFeatures.isAllowed(mProfileSupplier.get())
&& !ReadAloudFeatures.isInMultiWindowAndDisabled(mActivity);
}
/** Returns true if the web contents within current Tab is readable. */
public boolean isReadable(Tab tab) {
// If we don't have a valid Profile, playback won't work.
// TODO(crbug.com/41491180): Remove when valid profile is guaranteed.
if (tab == null
|| GURL.isEmptyOrInvalid(tab.getUrl())
|| tab.getWebContents() == null
|| mProfileSupplier.get() == null
|| !mProfileSupplier.get().isNativeInitialized()
|| DeviceConditions.getCurrentNetConnectionType(mActivity.getApplicationContext())
== ConnectionType.CONNECTION_NONE) {
return false;
}
if (isTabLanguageSupported(tab) && isAvailable()) {
int sanitizedUrlHash = urlToHash(stripUserData(tab.getUrl()).getSpec());
ReadabilityInfo info = getReadabilityInfoIfUnexpired(sanitizedUrlHash);
if (info != null) {
return info.isReadable();
}
}
return false;
}
/**
* Add a runnable to be called when new readability information is available for any page.
* Listeners can then call isReadable() to check a tab's readability.
*
* @param runnable Runnable called when a readability check succeeds or when a page is
* translated.
*/
public void addReadabilityUpdateListener(Runnable runnable) {
mReadabilityUpdateObserverList.addObserver(runnable);
}
/**
* Remove a runnable previously registered with addReadabilityUpdateListener. No effect if
* runnable was not added.
*
* @param runnable Runnable to remove.
*/
public void removeReadabilityUpdateListener(Runnable runnable) {
mReadabilityUpdateObserverList.removeObserver(runnable);
}
/** Returns true if the tab's current language is supported by the available voices. */
private boolean isTabLanguageSupported(Tab tab) {
if (mReadabilityHooks == null) {
return false;
}
String playbackLanguage = getLanguageForNewPlayback(tab);
return mReadabilityHooks.getCompatibleLanguages().contains(playbackLanguage);
}
/**
* Returns true if playback is being restored for a previously playing tab. True from
* onTabSelected() until the mini player is fully shown.
*/
public boolean isRestoringPlayer() {
return mRestoringPlayer;
}
/**
* Play the tab, creating and showing the player if it isn't already showing. No effect if tab's
* URL is the same as the URL that is already playing.
*
* @param tab Tab to play.
*/
public void playTab(Tab tab, @Entrypoint int entrypoint) {
if (!isReadable(tab)) {
ReadAloudMetrics.recordPlaybackWithoutReadabilityCheck(
entrypoint, Entrypoint.NUM_ENTRIES);
if (GURL.isEmptyOrInvalid(tab.getUrl())) {
ReadAloudMetrics.recordEmptyURLPlayback(entrypoint, Entrypoint.NUM_ENTRIES);
}
}
// Should rarely ever happen since the profile has to be established for a readability check
// to show the entrypoint.
if (mProfileSupplier.get() == null) {
return;
}
// There may be a saved playback state if another ReadAloudController instance interrupted
// this one. Restore it if the entrypoint is showing on the same tab, otherwise clear state.
if (mStateToRestoreOnBringingToForeground != null) {
if (mStateToRestoreOnBringingToForeground.getTab() == tab) {
restoreStateOnForeground();
return;
} else {
mStateToRestoreOnBringingToForeground = null;
}
}
extractDateModified(tab)
.then(
timestamp -> {
ReadAloudMetrics.recordHasDateModified(true);
playTabWithDateModified(tab, timestamp, entrypoint);
},
exception -> {
ReadAloudMetrics.recordHasDateModified(false);
playTabWithDateModified(tab, 0L, entrypoint);
});
}
private void playTabWithDateModified(Tab tab, long dateModified, @Entrypoint int entrypoint) {
createTabPlayback(tab, dateModified, entrypoint)
.then(
playback -> {
mDateModified = dateModified;
mPlayerCoordinator.playbackReady(playback, PLAYING);
playback.play();
ReadAloudMetrics.recordPlaybackStarted();
},
exception -> {
Log.d(TAG, "playTab failed: %s", exception.getMessage());
});
}
private Promise<Long> extractDateModified(Tab tab) {
assert !GURL.isEmptyOrInvalid(tab.getUrl());
maybeInitializePlaybackHooks();
if (mExtractor == null) {
mExtractor = mPlaybackHooks.createExtractor();
}
return mExtractor.getDateModified(tab);
}
private void maybeInitializePlaybackHooks() {
if (mPlaybackHooks == null) {
mPlaybackHooks =
sPlaybackHooksForTesting != null
? sPlaybackHooksForTesting
: ReadAloudPlaybackHooksProvider.getForProfile(mProfileSupplier.get());
mPlayerCoordinator = mPlaybackHooks.createPlayer(/* delegate= */ this);
mPlayerCoordinator.addObserver(this);
}
}
private Promise<Playback> createTabPlayback(
Tab tab, long dateModified, @Entrypoint int entrypoint) {
assert !GURL.isEmptyOrInvalid(tab.getUrl());
// only start a new playback if different URL or no active playback for that url
if (mActivePlaybackTabSupplier.get() != null
&& tab.getUrl().equals(mActivePlaybackTabSupplier.get().getUrl())) {
var promise = new Promise<Playback>();
promise.reject(new Exception("Tab already playing"));
return promise;
}
// If there is a background playback from another instance, stop it.
stopExternalBackgroundPlayback();
// Stop ongoing playback in this activity.
resetCurrentPlayback(ReasonForStoppingPlayback.NEW_PLAYBACK_REQUEST);
mActivePlaybackTabSupplier.set(tab);
mPlayingTabTranslationObserver.observeTab(mActivePlaybackTabSupplier.get());
mCurrentlyPlayingGurl = tab.getUrl();
if (!mPlaybackHooks.voicesInitialized()) {
mPlaybackHooks.initVoices();
}
// Notify player UI that playback is happening soon and show UI in case there's an error
// coming.
mPlayerCoordinator.playTabRequested();
final String playbackLanguage = getLanguageForNewPlayback(tab);
boolean isTranslated = isTranslated(tab);
var voices = mPlaybackHooks.getVoicesFor(playbackLanguage);
// TODO: Don't show entrypoints for unsupported languages
if (voices == null || voices.isEmpty()) {
onCreatePlaybackFailed(entrypoint);
var promise = new Promise<Playback>();
promise.reject(new Exception("Unsupported language"));
return promise;
}
final String sanitizedUrl = stripUserData(tab.getUrl()).getSpec();
final int sanitizedUrlHash = urlToHash(sanitizedUrl);
PlaybackArgs args =
new PlaybackArgs(
sanitizedUrl,
isTranslated ? playbackLanguage : null,
mPlaybackHooks.getPlaybackVoiceList(
ReadAloudPrefs.getVoices(getPrefService())),
/* dateModifiedMsSinceEpoch= */ dateModified);
Log.d(TAG, "Creating playback with args: %s", args);
Promise<Playback> promise = createPlayback(args);
promise.then(
playback -> {
ReadAloudMetrics.recordIsTabPlaybackCreationSuccessful(true);
ReadAloudMetrics.recordTabCreationSuccess(entrypoint, Entrypoint.NUM_ENTRIES);
maybeSetUpHighlighter(playback.getMetadata());
updateVoiceMenu(
isTranslated
? playbackLanguage
: getLanguage(playback.getMetadata().languageCode()));
mPlayback = playback;
mPlayback.addListener(ReadAloudController.this);
},
exception -> {
Log.e(TAG, exception.getMessage());
if (exception instanceof ReadAloudUnsupportedException) {
Log.e(TAG, "Attempting to play a non readable website");
sReadabilityInfoMap.put(
sanitizedUrlHash,
new ReadabilityInfo(false, sClock.currentTimeMillis(), false));
notifyReadabilityMayHaveChanged();
}
onCreatePlaybackFailed(entrypoint);
});
return promise;
}
private void onCreatePlaybackFailed(@Entrypoint int entrypoint) {
ReadAloudMetrics.recordIsTabPlaybackCreationSuccessful(false);
ReadAloudMetrics.recordTabCreationFailure(entrypoint, Entrypoint.NUM_ENTRIES);
mPlayerCoordinator.playbackFailed();
}
/**
* Whether or not timepoints are supported for the tab's content. Timepoints are needed for word
* highlighting.
*/
public boolean timepointsSupported(Tab tab) {
if (isAvailable() && !GURL.isEmptyOrInvalid(tab.getUrl())) {
int urlHash = urlToHash(stripUserData(tab.getUrl()).getSpec());
if (sReadabilityInfoMap.get(urlHash) == null) {
return false;
}
return sReadabilityInfoMap.get(urlHash).getTimepointsSupported();
}
return false;
}
private void resetCurrentPlayback(@ReasonForStoppingPlayback int reason) {
// TODO(b/303294007): Investigate exception sometimes thrown by release().
if (mPlayback != null) {
maybeClearHighlights();
mPlayback.removeListener(this);
mPlayback.release();
mPlayback = null;
mPlayerCoordinator.recordPlaybackDuration();
ReadAloudMetrics.recordReasonForStoppingPlayback(reason);
if (mKeepScreenOnFlagIsSet) {
mActivity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
mKeepScreenOnFlagIsSet = false;
mPlayingTabTranslationObserver.stopObservingTab(null);
mActivePlaybackTabSupplier.set(null);
mCurrentlyPlayingGurl = null;
mGlobalRenderFrameId = null;
mCurrentPlaybackData = null;
mPausedForIncognito = false;
mDateModified = 0L;
}
/** Cleanup: unregister listeners. */
public void destroy() {
sInstances.remove(this);
mIsDestroyed = true;
if (mVoicePreviewPlayback != null) {
destroyVoicePreview();
}
// Stop playback and hide players.
if (mPlayerCoordinator != null) {
mPlayerCoordinator.destroy();
}
if (mTabObserver != null) {
mTabObserver.destroy();
}
removeTranslationObservers(null);
mHighlightingEnabled.removeObserver(mHighlightingEnabledObserver);
ApplicationStatus.unregisterApplicationStateListener(this);
ApplicationStatus.unregisterActivityStateListener(this);
resetCurrentPlayback(ReasonForStoppingPlayback.APP_DESTROYED);
mStateToRestoreOnBringingToForeground = null;
ReadAloudFeatures.shutdown();
InsetObserver insetObserver = mActivityWindowAndroid.getInsetObserver();
if (insetObserver != null) {
insetObserver.removeObserver(this);
}
mActivityLifecycleDispatcher.unregister(this);
mRestoringPlayer = false;
mReadabilityUpdateObserverList.clear();
if (NetworkChangeNotifier.isInitialized()) {
NetworkChangeNotifier.removeConnectionTypeObserver(this);
}
}
private void maybeSetUpHighlighter(Playback.Metadata metadata) {
boolean highlightingSupported = isHighlightingSupported();
ReadAloudMetrics.recordHighlightingSupported(highlightingSupported);
if (highlightingSupported) {
if (mHighlighter == null) {
mHighlighter = mPlaybackHooks.createHighlighter();
}
mHighlighterConfig = new Highlighter.Config(mActivity);
mHighlighterConfig.setMode(Mode.TEXT_HIGHLIGHTING_MODE_WORD);
Tab activePlaybackTab = mActivePlaybackTabSupplier.get();
mHighlighter.initializeJs(activePlaybackTab, metadata, mHighlighterConfig);
assert (activePlaybackTab.getWebContents() != null
&& activePlaybackTab.getWebContents().getMainFrame() != null);
if (activePlaybackTab.getWebContents() != null
&& activePlaybackTab.getWebContents().getMainFrame() != null) {
mGlobalRenderFrameId =
activePlaybackTab
.getWebContents()
.getMainFrame()
.getGlobalRenderFrameHostId();
}
}
}
/** Update the page highlighting setting. */
private void onHighlightingEnabledChanged(boolean enabled) {
ReadAloudPrefs.setHighlightingEnabled(getPrefService(), enabled);
if (!enabled) {
// clear highlighting
maybeClearHighlights();
}
}
private void maybeClearHighlights() {
if (mHighlighter != null
&& mGlobalRenderFrameId != null
&& mActivePlaybackTabSupplier.get() != null) {
mHighlighter.clearHighlights(mGlobalRenderFrameId, mActivePlaybackTabSupplier.get());
}
}
private void maybeHighlightText(PhraseTiming phraseTiming) {
if (mHighlightingEnabled.get()
&& mHighlighter != null
&& mGlobalRenderFrameId != null
&& mActivePlaybackTabSupplier.get() != null) {
mHighlighter.highlightText(
mGlobalRenderFrameId, mActivePlaybackTabSupplier.get(), phraseTiming);
}
}
/**
* Dismiss the player UI if present and stop and release playback if playing.
*
* @param tab if specified, a playback will be stopped if it was triggered for this tab; if null
* any active playback will be stopped.
* @param reason reason why the active playback is being stopped
*/
public void maybeStopPlayback(
@Nullable Tab tab, @ReasonForStoppingPlayback int reasonPlaybackStopped) {
if (mActivePlaybackTabSupplier.get() == null && mPlayerCoordinator != null) {
// in case there's an error and UI is drawn
mPlayerCoordinator.dismissPlayers();
} else if (mActivePlaybackTabSupplier.get() != null
&& (tab == null || mActivePlaybackTabSupplier.get().getId() == tab.getId())) {
mPlayerCoordinator.dismissPlayers();
resetCurrentPlayback(reasonPlaybackStopped);
}
}
/** Pause audio if playing. */
public void pause() {
if (mPlayback != null && mCurrentPlaybackData.state() == PLAYING) {
mPlayback.pause();
}
}
private void maybeHandleTabReload(Tab tab, GURL newUrl) {
assert tab.getUrl().getSpec().equals(newUrl.getSpec());
if (mHighlighter != null && !GURL.isEmptyOrInvalid(tab.getUrl())) {
mHighlighter.handleTabReloaded(tab);
}
}
private GURL stripUserData(GURL in) {
if (GURL.isEmptyOrInvalid(in)
|| (in.getUsername().isEmpty() && in.getPassword().isEmpty())) {
return in;
}
return in.replaceComponents(
/* username= */ null,
/* clearUsername= */ true,
/* password= */ null,
/* clearPassword= */ true);
}
private String getLanguageForNewPlayback(Tab tab) {
WebContents webContents = tab.getWebContents();
String language =
webContents == null ? null : TranslateBridge.getCurrentLanguage(webContents);
if (language == null || language.isEmpty() || language.equals("und")) {
language = AppLocaleUtils.getAppLanguagePref();
}
if (language == null) {
language = "en";
}
// If language string is a locale like "en-US", strip the "-US" part.
return getLanguage(language);
}
/** A utility function doing null checks. */
boolean isTranslated(Tab tab) {
return tab.getWebContents() == null
? false
: TranslateBridge.isPageTranslated(tab.getWebContents());
}
/** If language string includes locale, strip it */
private String getLanguage(String language) {
if (language.contains("-")) {
return language.split("-")[0];
}
return language;
}
private void updateVoiceMenu(@Nullable String language) {
if (language == null) {
return;
}
List<PlaybackVoice> voices = mPlaybackHooks.getVoicesFor(language);
mCurrentLanguageVoices.set(voices);
String selectedVoiceId = ReadAloudPrefs.getVoices(getPrefService()).get(language);
if (selectedVoiceId == null) {
selectedVoiceId = voices.get(0).getVoiceId();
}
mSelectedVoiceId.set(selectedVoiceId);
}
/**
* Pause if the given intent is for processing text.
*
* @param intent Intent being sent by Chrome.
*/
public void maybePauseForOutgoingIntent(@Nullable Intent intent) {
if (intent != null && intent.getAction().equals(Intent.ACTION_PROCESS_TEXT)) {
pause();
}
}
// Player.Delegate
@Override
public BottomSheetController getBottomSheetController() {
return mBottomSheetController;
}
@Override
public boolean isHighlightingSupported() {
if (mActivePlaybackTabSupplier.get() == null) {
return false;
}
return timepointsSupported(mActivePlaybackTabSupplier.get())
&& !isTranslated(mActivePlaybackTabSupplier.get());
}
@Override
public ObservableSupplierImpl<Boolean> getHighlightingEnabledSupplier() {
return mHighlightingEnabled;
}
@Override
public void setHighlighterMode(@Highlighter.Mode int mode) {
// Highlighter initialization is expensive, so only do it if necessary
if (mHighlighter != null
&& mHighlighterConfig != null
&& mode != mHighlighterConfig.getMode()
&& mPlayback != null) {
mHighlighterConfig.setMode(mode);
mHighlighter.handleTabReloaded(mActivePlaybackTabSupplier.get());
mHighlighter.initializeJs(
mActivePlaybackTabSupplier.get(), mPlayback.getMetadata(), mHighlighterConfig);
}
}
@Override
public ObservableSupplier<List<PlaybackVoice>> getCurrentLanguageVoicesSupplier() {
return mCurrentLanguageVoices;
}
@Override
public ObservableSupplier<String> getVoiceIdSupplier() {
return mSelectedVoiceId;
}
@Override
public void setVoiceOverrideAndApplyToPlayback(PlaybackVoice voice) {
ReadAloudPrefs.setVoice(getPrefService(), voice.getLanguage(), voice.getVoiceId());
mSelectedVoiceId.set(voice.getVoiceId());
if (mActivePlaybackTabSupplier.get() != null && mPlayback != null) {
assert !GURL.isEmptyOrInvalid(mActivePlaybackTabSupplier.get().getUrl());
RestoreState state =
new RestoreState(
mActivePlaybackTabSupplier.get(), mCurrentPlaybackData, mDateModified);
resetCurrentPlayback(ReasonForStoppingPlayback.VOICE_CHANGE);
// This should re-request playback with the same playback state and paragraph
// and the new voice.
state.restore();
}
}
@Override
public Promise<Playback> previewVoice(PlaybackVoice voice) {
// Only one playback possible at a time, so current playback must be stopped and
// cleaned up. May be null if the most recent playback was a voice preview.
if (mActivePlaybackTabSupplier.get() != null) {
mStateToRestoreOnVoiceMenuClose =
new RestoreState(
mActivePlaybackTabSupplier.get(), mCurrentPlaybackData, mDateModified);
resetCurrentPlayback(ReasonForStoppingPlayback.VOICE_PREVIEW);
}
if (mVoicePreviewPlayback != null) {
destroyVoicePreview();
}
// If there is a background playback from another instance, stop it. (The voice selection UI
// should not be visible in this case, so it should not be possible to preview a voice, but
// this ensures the preview still works anyway.)
stopExternalBackgroundPlayback();
Log.d(
TAG,
"Requested preview of voice %s from language %s",
voice.getVoiceId(),
voice.getLanguage());
PlaybackArgs args =
new PlaybackArgs(
mActivity.getString(R.string.readaloud_voice_preview_message),
/* isUrl= */ false,
voice.getLanguage(),
mPlaybackHooks.getPlaybackVoiceList(
Map.of(voice.getLanguage(), voice.getVoiceId())),
/* dateModifiedMsSinceEpoch= */ 0);
Log.d(TAG, "Voice preview args: %s", args);
Promise<Playback> promise = createPlayback(args);
promise.then(
playback -> {
Log.d(TAG, "Voice preview playback created.");
mVoicePreviewPlayback = playback;
playback.addListener(mVoicePreviewPlaybackListener);
mVoicePreviewPlayback.play();
},
exception -> {
Log.e(TAG, "Failed to create voice preview: %s", exception.getMessage());
});
return promise;
}
private void destroyVoicePreview() {
mVoicePreviewPlayback.removeListener(mVoicePreviewPlaybackListener);
mVoicePreviewPlayback.release();
mVoicePreviewPlayback = null;
}
private Promise<Playback> createPlayback(PlaybackArgs args) {
final var promise = new Promise<Playback>();
if (mProfileSupplier.get() == null || !mProfileSupplier.get().isNativeInitialized()) {
promise.reject(new Exception("missing profile"));
return promise;
}
mPlaybackHooks.createPlayback(
args,
new ReadAloudPlaybackHooks.CreatePlaybackCallback() {
@Override
public void onSuccess(Playback playback) {
if (playback == null) {
promise.reject(new Exception("Playback is null"));
}
// Check if in multi-window mode and not supporting multi-window
// This failure will also trigger when the user goes into multi-window mode
// with a playback since we will attempt to restore
if (ReadAloudFeatures.isInMultiWindowAndDisabled(mActivity)) {
playback.release();
promise.reject(new Exception("In multi window mode"));
return;
}
// If we rely on the backend to detect page language, ensure it is supported
if (args.getLanguage() == null
&& !mReadabilityHooks
.getCompatibleLanguages()
.contains(
getLanguage(
playback.getMetadata().languageCode()))) {
playback.release();
promise.reject(new Exception("Unsupported language"));
return;
}
promise.fulfill(playback);
}
@Override
public void onFailure(Throwable throwable) {
if (throwable instanceof Exception) {
promise.reject((Exception) throwable);
} else {
promise.reject(new Exception(throwable));
}
}
});
return promise;
}
@Override
public ActivityLifecycleDispatcher getActivityLifecycleDispatcher() {
return mActivityLifecycleDispatcher;
}
@Override
public void navigateToPlayingTab() {
if (mActivePlaybackTabSupplier.get() == null) {
return;
}
if (mTabModel.indexOf(mActivePlaybackTabSupplier.get()) != TabModel.INVALID_TAB_INDEX) {
mTabModel.setIndex(
mTabModel.indexOf(mActivePlaybackTabSupplier.get()),
TabSelectionType.FROM_USER);
}
}
@Override
public Activity getActivity() {
return mActivity;
}
@Override
public PrefService getPrefService() {
return UserPrefs.get(mProfileSupplier.get());
}
@Override
public BottomControlsStacker getBottomControlsStacker() {
return mBottomControlsStacker;
}
@Override
@Nullable
public LayoutManager getLayoutManager() {
return mLayoutManagerSupplier.get();
}
// Player.Observer
@Override
public void onRequestClosePlayers() {
maybeStopPlayback(mActivePlaybackTabSupplier.get(), ReasonForStoppingPlayback.MANUAL_CLOSE);
}
@Override
public void onVoiceMenuClosed() {
if (mVoicePreviewPlayback != null) {
destroyVoicePreview();
}
if (mStateToRestoreOnVoiceMenuClose != null) {
mStateToRestoreOnVoiceMenuClose.restore();
mStateToRestoreOnVoiceMenuClose = null;
}
}
@Override
public void onMiniPlayerShown() {
mRestoringPlayer = false;
}
@Override
@Nullable
public Profile getProfile() {
return mProfileSupplier.get();
}
@Override
public UserEducationHelper getUserEducationHelper() {
return mUserEducationHelper;
}
// InsetObserver.WindowInsetObserver
@Override
public void onKeyboardInsetChanged(int inset) {
if (inset > 0) {
maybeHidePlayer();
} else {
maybeShowPlayer();
}
}
// NetworkChangeNotifier.ConnectionTypeObserver
@Override
public void onConnectionTypeChanged(int connectionType) {
notifyReadabilityMayHaveChanged();
}
/** Show mini player if there is an active playback. */
public void maybeShowPlayer() {
if (mPlayback != null) {
mPlayerCoordinator.restorePlayers();
}
}
/**
* If there's an active playback, this method will hide the player (either the mini player or
* the expanded player - whichever is showing) without stopping audio. To bring back the player
* UI, call {@link #maybeShowPlayer() maybeShowPlayer}
*/
public void maybeHidePlayer() {
if (mPlayback != null) {
mPlayerCoordinator.hidePlayers();
}
}
// PlaybackListener methods
@Override
public void onPhraseChanged(PhraseTiming phraseTiming) {
maybeHighlightText(phraseTiming);
}
@Override
public void onPlaybackDataChanged(PlaybackData data) {
if (data.state() == PLAYING) {
if (!mKeepScreenOnFlagIsSet) {
mActivity.getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
mKeepScreenOnFlagIsSet = true;
}
} else {
if (mKeepScreenOnFlagIsSet) {
mKeepScreenOnFlagIsSet = false;
mActivity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
mCurrentPlaybackData = data;
}
@Override
public void onApplicationStateChange(@ApplicationState int newState) {
boolean isScreenOnAndUnlocked =
DeviceConditions.isCurrentlyScreenOnAndUnlocked(mActivity.getApplicationContext());
if (ReadAloudFeatures.isBackgroundPlaybackEnabled() && mPlayerCoordinator != null) {
if (mIsScreenOnAndUnlocked != isScreenOnAndUnlocked) {
mPlayerCoordinator.onScreenStatusChanged(/* isLocked= */ !isScreenOnAndUnlocked);
mIsScreenOnAndUnlocked = isScreenOnAndUnlocked;
}
// Do nothing Chrome doesn't have to be in foreground to keep playback active.
return;
}
// If background playback is disabled, stop any playback if user left Chrome while screen is
// on and unlocked
if (newState == ApplicationState.HAS_STOPPED_ACTIVITIES
&& (isScreenOnAndUnlocked || mOnUserLeaveHint)) {
saveStateToRestoreOnForeground();
resetCurrentPlayback(ReasonForStoppingPlayback.APP_BACKGROUNDED);
mOnUserLeaveHint = false;
}
if (mPlayerCoordinator != null) {
if (newState == ApplicationState.HAS_STOPPED_ACTIVITIES && !isScreenOnAndUnlocked) {
mPlayerCoordinator.onScreenStatusChanged(/* isLocked= */ true);
} else if (newState == ApplicationState.HAS_RUNNING_ACTIVITIES
&& isScreenOnAndUnlocked) {
mPlayerCoordinator.onScreenStatusChanged(/* isLocked= */ false);
}
}
}
@Override
public void onActivityStateChange(Activity activity, @ActivityState int newState) {
// Restore saved playback if resuming after app was backgrounded.
if (newState == ActivityState.RESUMED
&& mStateToRestoreOnBringingToForeground != null
&& mProfileSupplier.get() != null) {
boolean otherInstanceHasPlayback = false;
for (ReadAloudController controller : sInstances) {
if (controller != this && controller.mPlayback != null) {
otherInstanceHasPlayback = true;
break;
}
}
// Only resume if another instance doesn't already have a playback.
if (!otherInstanceHasPlayback) {
restoreStateOnForeground();
mOnUserLeaveHint = false;
}
// TODO(b/352563278): If another instance is playing, show the player in the "paused"
// state and then restore if the play button is clicked.
}
}
// OnUserLeaveHintObserver
@Override
public void onUserLeaveHint() {
Log.d(TAG, "on user leave hint");
mOnUserLeaveHint = true;
}
/** if the current focused tab has an active playback */
public boolean isPlayingCurrentTab() {
return mPlayback != null
&& mActivePlaybackTabSupplier.get() != null
&& mActivePlaybackTabSupplier.get() == mTabModel.getCurrentTabSupplier().get();
}
public ObservableSupplier<Tab> getActivePlaybackTabSupplier() {
return mActivePlaybackTabSupplier;
}
/**
* TODO(crbug.com/305737581): finish implementation.
*
* @param content Selected word and surrounding content
* @param beginOffset index of where the selected word starts within the content
* @param endOffset index of where the selected word ends within the content
*/
public void tapToSeek(String content, int beginOffset, int endOffset) {
if (ReadAloudFeatures.isTapToSeekEnabled() && isPlayingCurrentTab()) {
long timeWhenTapToSeekRequested = sClock.currentTimeMillis();
TapToSeekHandler.tapToSeek(
content,
beginOffset,
endOffset,
mPlayback,
mCurrentPlaybackData.state() == PLAYING);
ReadAloudMetrics.recordTapToSeekTime(
sClock.currentTimeMillis() - timeWhenTapToSeekRequested);
}
}
private void notifyReadabilityMayHaveChanged() {
for (Runnable observer : mReadabilityUpdateObserverList) {
observer.run();
}
}
private void removeTranslationObservers(Tab tab) {
mPlayingTabTranslationObserver.stopObservingTab(tab);
mCurrentTabTranslationObserver.stopObservingTab(tab);
}
private void saveStateToRestoreOnForeground() {
if (mActivePlaybackTabSupplier.get() == null) return;
mStateToRestoreOnBringingToForeground =
new RestoreState(
mActivePlaybackTabSupplier.get(),
mCurrentPlaybackData,
/* useOffsetInParagraph= */ true,
/* shouldPlayOverride= */ false,
mDateModified);
}
private void restoreStateOnForeground() {
mStateToRestoreOnBringingToForeground.restore();
mStateToRestoreOnBringingToForeground = null;
}
private void stopExternalBackgroundPlayback() {
for (ReadAloudController controller : sInstances) {
if (controller != this && controller.mPlayback != null) {
controller.saveStateToRestoreOnForeground();
controller.maybeStopPlayback(
null, ReasonForStoppingPlayback.EXTERNAL_PLAYBACK_REQUEST);
}
}
}
// Tests.
public void setHighlighterForTests(Highlighter highighter) {
mHighlighter = highighter;
}
public void setTimepointsSupportedForTest(String url, boolean supported) {
sReadabilityInfoMap.put(urlToHash(url), new ReadabilityInfo(true, 0L, supported));
}
public void setStateToRestoreOnBringingToForegroundForTests(RestoreState restoreState) {
mStateToRestoreOnBringingToForeground = restoreState;
}
public TabModelTabObserver getTabModelTabObserverforTests() {
return mTabObserver;
}
public TabModelTabObserver getIncognitoTabModelTabObserverforTests() {
return mIncognitoTabObserver;
}
public TranslationObserver getTranslationObserverForTest() {
return mPlayingTabTranslationObserver;
}
public TranslationObserver getCurrentTabTranslationObserverForTest() {
return mCurrentTabTranslationObserver;
}
private int urlToHash(String url) {
return Hashing.murmur3_32_fixed().hashUnencodedChars(url).asInt();
}
static void resetReadabilityCacheForTesting() {
sReadabilityInfoMap.evictAll();
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public static void setReadabilityHooks(ReadAloudReadabilityHooks hooks) {
sReadabilityHooksForTesting = hooks;
ResettersForTesting.register(() -> sReadabilityHooksForTesting = null);
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public static void setPlaybackHooks(ReadAloudPlaybackHooks hooks) {
sPlaybackHooksForTesting = hooks;
ResettersForTesting.register(() -> sPlaybackHooksForTesting = null);
}
@VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE)
public void setActivePlaybackTab(Tab tab) {
mActivePlaybackTabSupplier.set(tab);
}
}