| // 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 androidx.annotation.IntDef; |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| |
| import org.chromium.base.ApplicationState; |
| import org.chromium.base.ApplicationStatus; |
| import org.chromium.base.Log; |
| import org.chromium.base.Promise; |
| import org.chromium.base.ResettersForTesting; |
| 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.chrome.browser.browser_controls.BrowserControlsSizer; |
| 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.tab.Tab; |
| import org.chromium.chrome.browser.tab.TabSelectionType; |
| import org.chromium.chrome.browser.tabmodel.TabModel; |
| import org.chromium.chrome.browser.tabmodel.TabModelTabObserver; |
| import org.chromium.chrome.browser.translate.TranslateBridge; |
| import org.chromium.chrome.browser.translate.TranslationObserver; |
| 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.browser_ui.widget.InsetObserver; |
| import org.chromium.components.browser_ui.widget.InsetObserverSupplier; |
| 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.NavigationHandle; |
| import org.chromium.content_public.browser.WebContents; |
| import org.chromium.ui.base.ActivityWindowAndroid; |
| import org.chromium.ui.base.WindowAndroid; |
| import org.chromium.url.GURL; |
| |
| import java.util.HashMap; |
| 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, |
| ApplicationStatus.ApplicationStateListener, |
| InsetObserver.WindowInsetObserver, |
| OnUserLeaveHintObserver { |
| private static final String TAG = "ReadAloudController"; |
| private static final Class<RestoreState> USER_DATA_KEY = RestoreState.class; |
| private final Activity mActivity; |
| private final ObservableSupplier<Profile> mProfileSupplier; |
| private final ObservableSupplierImpl<String> mReadabilitySupplier = |
| new ObservableSupplierImpl(); |
| private final Map<String, String> mSanitizedToFullUrlMap = new HashMap<>(); |
| private final Map<String, Boolean> mReadabilityMap = new HashMap<>(); |
| private final Map<String, Boolean> mTimepointsSupportedMap = new HashMap<>(); |
| private final HashSet<String> mPendingRequests = new HashSet<>(); |
| private final TabModel mTabModel; |
| private final TabModel mIncognitoTabModel; |
| @Nullable private Player mPlayerCoordinator; |
| private final ObservableSupplier<LayoutManager> mLayoutManagerSupplier; |
| private final TapToSeekHandler mTapToSeekHandler; |
| |
| private TabModelTabObserver mTabObserver; |
| private TabModelTabObserver mIncognitoTabObserver; |
| |
| private boolean mPausedForIncognito; |
| |
| private final BottomSheetController mBottomSheetController; |
| private final BrowserControlsSizer mBrowserControlsSizer; |
| 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 the mCurrentlyPlayingTab and mGlobalRenderFrameId |
| @Nullable private Playback mPlayback; |
| @Nullable private Tab mCurrentlyPlayingTab; |
| @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; |
| |
| // TODO(b/322052505): Remove this and just observe mProfileSupplier. |
| @Nullable private Profile mProfile; |
| |
| private boolean mOnUserLeaveHint; |
| |
| /** |
| * 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; |
| } |
| |
| // 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. |
| private 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) { |
| 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() { |
| 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; |
| |
| private long mTranslationObserverHandle; |
| private final TranslationObserver mTranslationObserver = |
| new TranslationObserver() { |
| @Override |
| public void onIsPageTranslatedChanged(WebContents webContents) { |
| if (mCurrentlyPlayingTab != null) { |
| maybeStopPlayback(mCurrentlyPlayingTab); |
| } |
| } |
| |
| @Override |
| public void onPageTranslated( |
| String sourceLanguage, String translatedLanguage, int errorCode) { |
| if (mCurrentlyPlayingTab != null && errorCode == 0) { |
| maybeStopPlayback(mCurrentlyPlayingTab); |
| } |
| } |
| }; |
| |
| /** |
| * 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. |
| * TODO: Add optimizations (don't send requests on chrome:// pages, remove password from the |
| * url, etc). Also include enterprise policy check. |
| */ |
| private ReadAloudReadabilityHooks.ReadabilityCallback mReadabilityCallback = |
| new ReadAloudReadabilityHooks.ReadabilityCallback() { |
| @Override |
| public void onSuccess(String url, boolean isReadable, boolean timepointsSupported) { |
| Log.d(TAG, "onSuccess called for %s", url); |
| ReadAloudMetrics.recordIsPageReadable(isReadable); |
| ReadAloudMetrics.recordServerReadabilityResult(isReadable); |
| ReadAloudMetrics.recordIsPageReadabilitySuccessful(true); |
| |
| // Register _KnownReadable trial before checking more playback conditions |
| if (isReadable) { |
| ReadAloudFeatures.activateKnownReadableTrial(); |
| } |
| |
| // isPlaybackEnabled() should only be checked if isReadable == true. |
| isReadable = isReadable && ReadAloudFeatures.isPlaybackEnabled(); |
| |
| mReadabilityMap.put(url, isReadable); |
| mTimepointsSupportedMap.put(url, timepointsSupported); |
| mPendingRequests.remove(url); |
| mReadabilitySupplier.set(mSanitizedToFullUrlMap.get(url)); |
| } |
| |
| @Override |
| public void onFailure(String url, Throwable t) { |
| Log.d(TAG, "onFailure called for %s because %s", url, t); |
| ReadAloudMetrics.recordIsPageReadabilitySuccessful(false); |
| mPendingRequests.remove(url); |
| } |
| }; |
| |
| private PlaybackListener mVoicePreviewPlaybackListener = |
| new PlaybackListener() { |
| @Override |
| public void onPlaybackDataChanged(PlaybackData data) { |
| if (data.state() == PlaybackListener.State.STOPPED) { |
| destroyVoicePreview(); |
| } |
| } |
| }; |
| |
| public ReadAloudController( |
| Activity activity, |
| ObservableSupplier<Profile> profileSupplier, |
| TabModel tabModel, |
| TabModel incognitoTabModel, |
| BottomSheetController bottomSheetController, |
| BrowserControlsSizer browserControlsSizer, |
| ObservableSupplier<LayoutManager> layoutManagerSupplier, |
| ActivityWindowAndroid activityWindowAndroid, |
| ActivityLifecycleDispatcher activityLifecycleDispatcher) { |
| ReadAloudFeatures.init(); |
| mActivity = activity; |
| mProfileSupplier = profileSupplier; |
| new OneShotCallback<Profile>(mProfileSupplier, this::onProfileAvailable); |
| mTabModel = tabModel; |
| mIncognitoTabModel = incognitoTabModel; |
| mBottomSheetController = bottomSheetController; |
| mCurrentLanguageVoices = new ObservableSupplierImpl<>(); |
| mSelectedVoiceId = new ObservableSupplierImpl<>(); |
| mBrowserControlsSizer = browserControlsSizer; |
| mLayoutManagerSupplier = layoutManagerSupplier; |
| mHighlightingEnabled = new ObservableSupplierImpl<>(false); |
| ApplicationStatus.registerApplicationStateListener(this); |
| mActivityWindowAndroid = activityWindowAndroid; |
| mActivityLifecycleDispatcher = activityLifecycleDispatcher; |
| mActivityLifecycleDispatcher.register(this); |
| mTapToSeekHandler = new TapToSeekHandler(mTabModel.getCurrentTabSupplier()); |
| } |
| |
| public ObservableSupplier<String> getReadabilitySupplier() { |
| return mReadabilitySupplier; |
| } |
| |
| @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) |
| public void onProfileAvailable(Profile profile) { |
| mProfile = profile; |
| mReadabilityHooks = |
| sReadabilityHooksForTesting != null |
| ? sReadabilityHooksForTesting |
| : new ReadAloudReadabilityHooksImpl(mActivity, profile); |
| if (mReadabilityHooks.isEnabled()) { |
| boolean isAllowed = ReadAloudFeatures.isAllowed(mProfileSupplier.get()); |
| ReadAloudMetrics.recordIsUserEligible(isAllowed); |
| if (!isAllowed) { |
| ReadAloudMetrics.recordIneligibilityReason( |
| ReadAloudFeatures.getIneligibilityReason()); |
| } |
| mHighlightingEnabled.addObserver( |
| ReadAloudController.this::onHighlightingEnabledChanged); |
| mHighlightingEnabled.set(ReadAloudPrefs.isHighlightingEnabled(getPrefService())); |
| ReadAloudMetrics.recordHighlightingEnabledOnStartup(mHighlightingEnabled.get()); |
| mTabObserver = |
| new TabModelTabObserver(mTabModel) { |
| |
| @Override |
| public void onLoadStarted(Tab tab, boolean toDifferentDocument) { |
| Log.d(TAG, "onLoadStarted"); |
| if (tab != null |
| && tab.getUrl() != null |
| && tab.getUrl().isValid() |
| && toDifferentDocument) { |
| maybeCheckReadability(tab.getUrl()); |
| maybeHandleTabReload(tab, tab.getUrl()); |
| maybeStopPlayback(tab); |
| } |
| } |
| |
| @Override |
| public void onDidRedirectNavigation( |
| Tab tab, NavigationHandle navigationHandle) { |
| Log.d(TAG, "onDidRedirectNavigation"); |
| if (navigationHandle.getUrl() != null |
| && navigationHandle.getUrl().isValid()) { |
| maybeCheckReadability(navigationHandle.getUrl()); |
| } |
| } |
| |
| @Override |
| public void onPageLoadStarted(Tab tab, GURL url) { |
| Log.d(TAG, "onPageLoad called for %s", url.getPossiblyInvalidSpec()); |
| maybeCheckReadability(url); |
| maybeHandleTabReload(tab, url); |
| maybeStopPlayback(tab); |
| } |
| |
| @Override |
| public void onActivityAttachmentChanged( |
| Tab tab, @Nullable WindowAndroid window) { |
| super.onActivityAttachmentChanged(tab, window); |
| Log.d(TAG, "onActivityAttachmentChanged"); |
| if (mCurrentlyPlayingTab != null |
| && mCurrentlyPlayingTab.getId() == tab.getId()) { |
| Log.d(TAG, "Saving state"); |
| RestoreState state = |
| new RestoreState( |
| mCurrentlyPlayingTab, |
| mCurrentPlaybackData, |
| mDateModified); |
| tab.getUserDataHost().setUserData(USER_DATA_KEY, state); |
| } |
| maybeStopPlayback(tab); |
| } |
| |
| @Override |
| protected void onTabSelected(Tab tab) { |
| super.onTabSelected(tab); |
| if (tab != null && tab.getUrl() != null) { |
| Log.d( |
| TAG, |
| "onTabSelected called for " |
| + tab.getUrl().getPossiblyInvalidSpec()); |
| maybeCheckReadability(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())) { |
| 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); |
| } |
| } |
| } |
| |
| @Override |
| public void willCloseTab(Tab tab) { |
| Log.d(TAG, "WillCloseTab"); |
| maybeStopPlayback(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 = |
| InsetObserverSupplier.getValueOrNullFrom(mActivityWindowAndroid); |
| if (insetObserver != null) { |
| insetObserver.addObserver(this); |
| } |
| } |
| } |
| |
| @VisibleForTesting(otherwise = VisibleForTesting.PACKAGE_PRIVATE) |
| public void maybeCheckReadability(GURL url) { |
| if (!isAvailable()) { |
| return; |
| } |
| |
| if (mReadabilityHooks == null) { |
| return; |
| } |
| |
| if (mProfile == null || !mProfile.isNativeInitialized()) { |
| return; |
| } |
| |
| if (!isURLReadAloudSupported(url)) { |
| ReadAloudMetrics.recordIsPageReadable(false); |
| return; |
| } |
| |
| String urlSpec = stripUserData(url).getSpec(); |
| // TODO: 2 different URLs can have the same sanitized URL |
| mSanitizedToFullUrlMap.put(url.getSpec(), urlSpec); |
| if (mReadabilityMap.containsKey(urlSpec)) { |
| ReadAloudMetrics.recordIsPageReadable(mReadabilityMap.get(urlSpec)); |
| return; |
| } |
| |
| if (mPendingRequests.contains(urlSpec)) { |
| return; |
| } |
| |
| |
| mPendingRequests.add(urlSpec); |
| mReadabilityHooks.isPageReadable(urlSpec, mReadabilityCallback); |
| } |
| |
| /** |
| * Checks if URL is supported by Read Aloud before sending a readability request. |
| * Read Aloud won't be supported on the following URLs: |
| * - pages without an HTTP(S) scheme |
| * - myaccount.google.com and myactivity.google.com |
| * - www.google.com/... |
| * - Based on standards.google/processes/domains/domain-guidelines-by-use-case, |
| * www.google.com/... is reserved for Search features and content. |
| */ |
| public boolean isURLReadAloudSupported(GURL url) { |
| return url.isValid() |
| && !url.isEmpty() |
| && (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/1518203): Remove when valid profile is guaranteed. |
| if (mProfile == null || !mProfile.isNativeInitialized()) { |
| return false; |
| } |
| |
| if (isTabLanguageSupported(tab) && isAvailable() && tab.getUrl().isValid()) { |
| Boolean isReadable = mReadabilityMap.get(stripUserData(tab.getUrl()).getSpec()); |
| return isReadable == null ? false : isReadable; |
| } |
| return false; |
| } |
| |
| /** 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); |
| } |
| |
| /** |
| * 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); |
| } |
| 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 tab.getUrl().isValid(); |
| 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 tab.getUrl().isValid(); |
| // only start a new playback if different URL or no active playback for that url |
| if (mCurrentlyPlayingTab != null && tab.getUrl().equals(mCurrentlyPlayingTab.getUrl())) { |
| var promise = new Promise<Playback>(); |
| promise.reject(new Exception("Tab already playing")); |
| return promise; |
| } |
| |
| resetCurrentPlayback(); |
| mCurrentlyPlayingTab = tab; |
| mTranslationObserverHandle = |
| mCurrentlyPlayingTab.getWebContents() != null |
| ? TranslateBridge.addTranslationObserver( |
| mCurrentlyPlayingTab.getWebContents(), mTranslationObserver) |
| : 0L; |
| |
| 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; |
| } |
| |
| PlaybackArgs args = |
| new PlaybackArgs( |
| stripUserData(tab.getUrl()).getSpec(), |
| 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()); |
| 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() && tab.getUrl().isValid()) { |
| Boolean timepointsSuported = |
| mTimepointsSupportedMap.get(stripUserData(tab.getUrl()).getSpec()); |
| return timepointsSuported == null ? false : timepointsSuported; |
| } |
| return false; |
| } |
| |
| private void resetCurrentPlayback() { |
| // TODO(b/303294007): Investigate exception sometimes thrown by release(). |
| if (mPlayback != null) { |
| maybeClearHighlights(); |
| mPlayback.removeListener(this); |
| mPlayback.release(); |
| mPlayback = null; |
| mPlayerCoordinator.recordPlaybackDuration(); |
| } |
| if (mTranslationObserverHandle != 0L) { |
| assert mCurrentlyPlayingTab != null; |
| TranslateBridge.removeTranslationObserver( |
| mCurrentlyPlayingTab.getWebContents(), mTranslationObserverHandle); |
| mTranslationObserverHandle = 0L; |
| } |
| mCurrentlyPlayingTab = null; |
| mGlobalRenderFrameId = null; |
| mCurrentPlaybackData = null; |
| mPausedForIncognito = false; |
| mDateModified = 0L; |
| } |
| |
| /** Cleanup: unregister listeners. */ |
| public void destroy() { |
| if (mVoicePreviewPlayback != null) { |
| destroyVoicePreview(); |
| } |
| |
| // Stop playback and hide players. |
| if (mPlayerCoordinator != null) { |
| mPlayerCoordinator.destroy(); |
| } |
| |
| if (mTabObserver != null) { |
| mTabObserver.destroy(); |
| } |
| mHighlightingEnabled.removeObserver(ReadAloudController.this::onHighlightingEnabledChanged); |
| ApplicationStatus.unregisterApplicationStateListener(this); |
| resetCurrentPlayback(); |
| mStateToRestoreOnBringingToForeground = null; |
| ReadAloudFeatures.shutdown(); |
| InsetObserver insetObserver = |
| InsetObserverSupplier.getValueOrNullFrom(mActivityWindowAndroid); |
| if (insetObserver != null) { |
| insetObserver.removeObserver(this); |
| } |
| mActivityLifecycleDispatcher.unregister(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); |
| mHighlighter.initializeJs(mCurrentlyPlayingTab, metadata, mHighlighterConfig); |
| assert (mCurrentlyPlayingTab.getWebContents() != null |
| && mCurrentlyPlayingTab.getWebContents().getMainFrame() != null); |
| if (mCurrentlyPlayingTab.getWebContents() != null |
| && mCurrentlyPlayingTab.getWebContents().getMainFrame() != null) { |
| mGlobalRenderFrameId = |
| mCurrentlyPlayingTab |
| .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 && mCurrentlyPlayingTab != null) { |
| mHighlighter.clearHighlights(mGlobalRenderFrameId, mCurrentlyPlayingTab); |
| } |
| } |
| |
| private void maybeHighlightText(PhraseTiming phraseTiming) { |
| if (mHighlightingEnabled.get() |
| && mHighlighter != null |
| && mGlobalRenderFrameId != null |
| && mCurrentlyPlayingTab != null) { |
| mHighlighter.highlightText(mGlobalRenderFrameId, mCurrentlyPlayingTab, 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. |
| */ |
| public void maybeStopPlayback(@Nullable Tab tab) { |
| if (mCurrentlyPlayingTab == null && mPlayerCoordinator != null) { |
| // in case there's an error and UI is drawn |
| mPlayerCoordinator.dismissPlayers(); |
| } else if (mCurrentlyPlayingTab != null |
| && (tab == null || mCurrentlyPlayingTab.getId() == tab.getId())) { |
| mPlayerCoordinator.dismissPlayers(); |
| resetCurrentPlayback(); |
| } |
| } |
| |
| /** Pause audio if playing. */ |
| public void pause() { |
| if (mPlayback != null && mCurrentPlaybackData.state() == PLAYING) { |
| mPlayback.pause(); |
| } |
| } |
| |
| private void maybeHandleTabReload(Tab tab, GURL newUrl) { |
| if (mHighlighter != null |
| && tab.getUrl() != null |
| && tab.getUrl().getSpec().equals(newUrl.getSpec())) { |
| mHighlighter.handleTabReloaded(tab); |
| } |
| } |
| |
| private GURL stripUserData(GURL in) { |
| if (!in.isValid() |
| || in.isEmpty() |
| || (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) { |
| Log.d(TAG, "Neither page nor app language known. Falling back to en."); |
| language = "en"; |
| } |
| |
| // If language string is a locale like "en-US", strip the "-US" part. |
| return getLanguage(language); |
| } |
| |
| /** A utinilty function doing null checks. */ |
| boolean isTranslated(Tab tab) { |
| return tab.getWebContents() == null |
| ? false |
| : TranslateBridge.isPageTranslated(tab.getWebContents()); |
| } |
| |
| /** Is 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 (mCurrentlyPlayingTab == null) { |
| return false; |
| } |
| return timepointsSupported(mCurrentlyPlayingTab) && !isTranslated(mCurrentlyPlayingTab); |
| } |
| |
| @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()) { |
| mHighlighterConfig.setMode(mode); |
| mHighlighter.handleTabReloaded(mCurrentlyPlayingTab); |
| mHighlighter.initializeJs( |
| mCurrentlyPlayingTab, 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 (mCurrentlyPlayingTab != null && mPlayback != null) { |
| RestoreState state = |
| new RestoreState(mCurrentlyPlayingTab, mCurrentPlaybackData, mDateModified); |
| resetCurrentPlayback(); |
| // 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 (mCurrentlyPlayingTab != null) { |
| mStateToRestoreOnVoiceMenuClose = |
| new RestoreState(mCurrentlyPlayingTab, mCurrentPlaybackData, mDateModified); |
| resetCurrentPlayback(); |
| } |
| |
| if (mVoicePreviewPlayback != null) { |
| destroyVoicePreview(); |
| } |
| |
| 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."); |
| ReadAloudMetrics.recordVoicePreviewed(voice.getVoiceId()); |
| 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 (mProfile == null || !mProfile.isNativeInitialized()) { |
| promise.reject(new Exception("missing profile")); |
| return promise; |
| } |
| mPlaybackHooks.createPlayback( |
| args, |
| new ReadAloudPlaybackHooks.CreatePlaybackCallback() { |
| @Override |
| public void onSuccess(Playback playback) { |
| // 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) { |
| promise.reject(new Exception(throwable)); |
| } |
| }); |
| return promise; |
| } |
| |
| @Override |
| public ActivityLifecycleDispatcher getActivityLifecycleDispatcher() { |
| return mActivityLifecycleDispatcher; |
| } |
| |
| @Override |
| public void navigateToPlayingTab() { |
| if (mCurrentlyPlayingTab == null) { |
| return; |
| } |
| if (mTabModel.indexOf(mCurrentlyPlayingTab) != TabModel.INVALID_TAB_INDEX) { |
| mTabModel.setIndex( |
| mTabModel.indexOf(mCurrentlyPlayingTab), TabSelectionType.FROM_USER, false); |
| } |
| } |
| |
| @Override |
| public Activity getActivity() { |
| return mActivity; |
| } |
| |
| @Override |
| public PrefService getPrefService() { |
| return UserPrefs.get(mProfileSupplier.get()); |
| } |
| |
| @Override |
| public BrowserControlsSizer getBrowserControlsSizer() { |
| return mBrowserControlsSizer; |
| } |
| |
| @Override |
| @Nullable |
| public LayoutManager getLayoutManager() { |
| return mLayoutManagerSupplier.get(); |
| } |
| |
| // Player.Observer |
| @Override |
| public void onRequestClosePlayers() { |
| maybeStopPlayback(mCurrentlyPlayingTab); |
| } |
| |
| @Override |
| public void onVoiceMenuClosed() { |
| if (mVoicePreviewPlayback != null) { |
| destroyVoicePreview(); |
| } |
| |
| if (mStateToRestoreOnVoiceMenuClose != null) { |
| mStateToRestoreOnVoiceMenuClose.restore(); |
| mStateToRestoreOnVoiceMenuClose = null; |
| } |
| } |
| |
| // InsetObserver.WindowInsetObserver |
| @Override |
| public void onKeyboardInsetChanged(int inset) { |
| if (inset > 0) { |
| maybeHidePlayer(); |
| } else { |
| maybeShowPlayer(); |
| } |
| } |
| |
| /** 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) { |
| mCurrentPlaybackData = data; |
| } |
| |
| @Override |
| public void onApplicationStateChange(@ApplicationState int newState) { |
| |
| boolean isScreenOnAndUnlocked = |
| DeviceConditions.isCurrentlyScreenOnAndUnlocked(mActivity.getApplicationContext()); |
| // stop any playback if user left Chrome while screen is on and unlocked |
| if (newState == ApplicationState.HAS_STOPPED_ACTIVITIES |
| && (isScreenOnAndUnlocked || mOnUserLeaveHint)) { |
| if (mCurrentlyPlayingTab != null) { |
| mStateToRestoreOnBringingToForeground = |
| new RestoreState( |
| mCurrentlyPlayingTab, |
| mCurrentPlaybackData, |
| /* useOffsetInParagraph= */ true, |
| /* shouldPlayOverride= */ false, |
| mDateModified); |
| } |
| resetCurrentPlayback(); |
| mOnUserLeaveHint = false; |
| } else if (newState == ApplicationState.HAS_RUNNING_ACTIVITIES |
| && mStateToRestoreOnBringingToForeground != null) { |
| mStateToRestoreOnBringingToForeground.restore(); |
| mStateToRestoreOnBringingToForeground = null; |
| 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); |
| } |
| } |
| } |
| |
| // OnUserLeaveHintObserver |
| @Override |
| public void onUserLeaveHint() { |
| Log.d(TAG, "on user leave hint"); |
| mOnUserLeaveHint = true; |
| } |
| |
| /** |
| * Triggered with ContextualSearch's onSelectionChange. Sends the selected webpage content and |
| * playback to TapToSeekHandler to find the selected word in the playback and seek to it. |
| * |
| * @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) { |
| mTapToSeekHandler.tapToSeek( |
| content, beginOffset, endOffset, mPlayback, mCurrentlyPlayingTab); |
| } |
| |
| // Tests. |
| public void setHighlighterForTests(Highlighter highighter) { |
| mHighlighter = highighter; |
| } |
| |
| public void setTimepointsSupportedForTest(String url, boolean supported) { |
| mTimepointsSupportedMap.put(url, supported); |
| } |
| |
| public TabModelTabObserver getTabModelTabObserverforTests() { |
| return mTabObserver; |
| } |
| |
| public TabModelTabObserver getIncognitoTabModelTabObserverforTests() { |
| return mIncognitoTabObserver; |
| } |
| |
| public TranslationObserver getTranslationObserverForTest() { |
| return mTranslationObserver; |
| } |
| |
| @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); |
| } |
| } |