| // Copyright 2020 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.feed; |
| |
| import android.animation.ObjectAnimator; |
| import android.animation.PropertyValuesHolder; |
| import android.app.Activity; |
| import android.util.DisplayMetrics; |
| import android.util.TypedValue; |
| import android.view.LayoutInflater; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewGroup.MarginLayoutParams; |
| import android.view.ViewParent; |
| import android.view.accessibility.AccessibilityEvent; |
| import android.widget.FrameLayout; |
| |
| import androidx.annotation.Nullable; |
| import androidx.annotation.VisibleForTesting; |
| import androidx.recyclerview.widget.RecyclerView; |
| import androidx.recyclerview.widget.RecyclerView.LayoutManager; |
| |
| import org.chromium.base.Callback; |
| import org.chromium.base.Log; |
| import org.chromium.base.ObserverList; |
| import org.chromium.base.ThreadUtils; |
| import org.chromium.base.annotations.CalledByNative; |
| import org.chromium.base.annotations.JNINamespace; |
| import org.chromium.base.annotations.NativeMethods; |
| import org.chromium.base.supplier.ObservableSupplier; |
| import org.chromium.base.supplier.ObservableSupplierImpl; |
| import org.chromium.base.supplier.Supplier; |
| import org.chromium.base.task.PostTask; |
| import org.chromium.base.task.TaskTraits; |
| import org.chromium.chrome.browser.feed.v2.FeedUserActionType; |
| import org.chromium.chrome.browser.feed.webfeed.WebFeedAvailabilityStatus; |
| import org.chromium.chrome.browser.feed.webfeed.WebFeedBridge; |
| import org.chromium.chrome.browser.feed.webfeed.WebFeedRecommendationFollowAcceleratorController; |
| import org.chromium.chrome.browser.feed.webfeed.WebFeedSnackbarController; |
| import org.chromium.chrome.browser.feed.webfeed.WebFeedSubscriptionRequestStatus; |
| import org.chromium.chrome.browser.feedback.HelpAndFeedbackLauncher; |
| import org.chromium.chrome.browser.flags.ChromeFeatureList; |
| import org.chromium.chrome.browser.share.ChromeShareExtras; |
| import org.chromium.chrome.browser.share.ShareDelegate; |
| import org.chromium.chrome.browser.ui.messages.snackbar.Snackbar; |
| import org.chromium.chrome.browser.ui.messages.snackbar.SnackbarManager; |
| import org.chromium.chrome.browser.xsurface.FeedActionsHandler; |
| import org.chromium.chrome.browser.xsurface.HybridListRenderer; |
| import org.chromium.chrome.browser.xsurface.ListLayoutHelper; |
| import org.chromium.chrome.browser.xsurface.LoggingParameters; |
| import org.chromium.chrome.browser.xsurface.SurfaceActionsHandler; |
| import org.chromium.chrome.browser.xsurface.SurfaceActionsHandler.OpenMode; |
| import org.chromium.chrome.browser.xsurface.SurfaceActionsHandler.OpenWebFeedEntryPoint; |
| import org.chromium.chrome.browser.xsurface.SurfaceScope; |
| import org.chromium.chrome.browser.xsurface.feed.StreamType; |
| import org.chromium.components.browser_ui.bottomsheet.BottomSheetContent; |
| import org.chromium.components.browser_ui.bottomsheet.BottomSheetController; |
| import org.chromium.components.browser_ui.bottomsheet.BottomSheetController.StateChangeReason; |
| import org.chromium.components.browser_ui.bottomsheet.EmptyBottomSheetObserver; |
| import org.chromium.components.browser_ui.share.ShareParams; |
| import org.chromium.components.browser_ui.widget.animation.Interpolators; |
| import org.chromium.components.feed.proto.FeedUiProto; |
| import org.chromium.components.signin.metrics.SigninAccessPoint; |
| import org.chromium.content_public.browser.LoadUrlParams; |
| import org.chromium.ui.base.PageTransition; |
| import org.chromium.ui.base.WindowAndroid; |
| import org.chromium.ui.display.DisplayAndroid; |
| import org.chromium.ui.mojom.WindowOpenDisposition; |
| import org.chromium.url.GURL; |
| |
| import java.io.UnsupportedEncodingException; |
| import java.nio.charset.StandardCharsets; |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.function.Function; |
| |
| /** |
| * A implementation of a Feed {@link Stream} that is just able to render a vertical stream of |
| * cards for Feed v2. |
| */ |
| @JNINamespace("feed::android") |
| public class FeedStream implements Stream { |
| private static final String TAG = "FeedStream"; |
| private static final String SPACER_KEY = "Spacer"; |
| |
| Function<String, GURL> mMakeGURL = url -> new GURL(url); |
| |
| /** |
| * Implementation of SurfaceActionsHandler methods. |
| */ |
| @VisibleForTesting |
| class FeedSurfaceActionsHandler implements SurfaceActionsHandler { |
| FeedActionDelegate mActionDelegate; |
| FeedSurfaceActionsHandler(FeedActionDelegate actionDelegate) { |
| mActionDelegate = actionDelegate; |
| } |
| |
| @Override |
| public void openUrl(@OpenMode int openMode, String url, OpenUrlOptions options) { |
| assert ThreadUtils.runningOnUiThread(); |
| switch (openMode) { |
| case OpenMode.UNKNOWN: |
| case OpenMode.SAME_TAB: |
| FeedStreamJni.get().reportOpenAction(mNativeFeedStream, FeedStream.this, |
| mMakeGURL.apply(url), getSliceIdFromView(options.actionSourceView()), |
| OpenActionType.DEFAULT); |
| openSuggestionUrl( |
| url, WindowOpenDisposition.CURRENT_TAB, /*inGroup=*/false, options); |
| break; |
| case OpenMode.NEW_TAB: |
| FeedStreamJni.get().reportOpenAction(mNativeFeedStream, FeedStream.this, |
| mMakeGURL.apply(url), getSliceIdFromView(options.actionSourceView()), |
| OpenActionType.NEW_TAB); |
| openSuggestionUrl(url, WindowOpenDisposition.NEW_BACKGROUND_TAB, |
| /*inGroup=*/false, options); |
| break; |
| case OpenMode.INCOGNITO_TAB: |
| FeedStreamJni.get().reportOtherUserAction(mNativeFeedStream, FeedStream.this, |
| FeedUserActionType.TAPPED_OPEN_IN_NEW_INCOGNITO_TAB); |
| openSuggestionUrl( |
| url, WindowOpenDisposition.OFF_THE_RECORD, /*inGroup=*/false, options); |
| break; |
| case OpenMode.DOWNLOAD_LINK: |
| FeedStreamJni.get().reportOtherUserAction( |
| mNativeFeedStream, FeedStream.this, FeedUserActionType.TAPPED_DOWNLOAD); |
| mActionDelegate.downloadPage(url); |
| break; |
| case OpenMode.READ_LATER: |
| FeedStreamJni.get().reportOtherUserAction(mNativeFeedStream, FeedStream.this, |
| FeedUserActionType.TAPPED_ADD_TO_READING_LIST); |
| mActionDelegate.addToReadingList(options.getTitle(), url); |
| break; |
| case OpenMode.NEW_TAB_IN_GROUP: |
| FeedStreamJni.get().reportOpenAction(mNativeFeedStream, FeedStream.this, |
| mMakeGURL.apply(url), getSliceIdFromView(options.actionSourceView()), |
| OpenActionType.NEW_TAB_IN_GROUP); |
| openSuggestionUrl(url, WindowOpenDisposition.NEW_BACKGROUND_TAB, |
| /*inGroup=*/true, options); |
| break; |
| } |
| |
| // Attempts to load more content if needed. |
| maybeLoadMore(); |
| } |
| |
| // Deprecated in favor of openUrl(), will be removed once internal references are removed. |
| @Override |
| public void navigateTab(String url, View actionSourceView) { |
| openUrl(OpenMode.SAME_TAB, url, new OpenUrlOptions() { |
| @Override |
| public View actionSourceView() { |
| return actionSourceView; |
| } |
| }); |
| } |
| |
| @Override |
| public void showBottomSheet(View view, View actionSourceView) { |
| assert ThreadUtils.runningOnUiThread(); |
| dismissBottomSheet(); |
| |
| FeedStreamJni.get().reportOtherUserAction( |
| mNativeFeedStream, FeedStream.this, FeedUserActionType.OPENED_CONTEXT_MENU); |
| |
| // Remember the currently focused view so that we can get back to it once the bottom |
| // sheet is closed. This is to fix the problem that the last focused view is not |
| // restored after opening and closing the bottom sheet. |
| mLastFocusedView = mActivity.getCurrentFocus(); |
| // If the talkback is enabled, also remember the accessibility focused view, which may |
| // be different from the focused view, so that we can get back to it once the bottom |
| // sheet is closed. |
| mLastAccessibilityFocusedView = findAccessibilityFocus(actionSourceView); |
| |
| // Make a sheetContent with the view. |
| mBottomSheetContent = new CardMenuBottomSheetContent(view); |
| mBottomSheetOriginatingSliceId = getSliceIdFromView(actionSourceView); |
| mBottomSheetController.addObserver(new EmptyBottomSheetObserver() { |
| @Override |
| public void onSheetClosed(@StateChangeReason int reason) { |
| if (mLastFocusedView != null) { |
| mLastFocusedView.requestFocus(); |
| mLastFocusedView = null; |
| } |
| if (mLastAccessibilityFocusedView != null) { |
| mLastAccessibilityFocusedView.sendAccessibilityEvent( |
| AccessibilityEvent.TYPE_VIEW_FOCUSED); |
| mLastAccessibilityFocusedView = null; |
| } |
| } |
| }); |
| mBottomSheetController.requestShowContent(mBottomSheetContent, true); |
| } |
| |
| @Override |
| public void dismissBottomSheet() { |
| FeedStream.this.dismissBottomSheet(); |
| } |
| |
| /** |
| * Search the view hierarchy to find the accessibility focused view. |
| */ |
| private View findAccessibilityFocus(View view) { |
| if (view == null || view.isAccessibilityFocused()) return view; |
| if (!(view instanceof ViewGroup)) return null; |
| ViewGroup viewGroup = (ViewGroup) view; |
| for (int i = 0; i < viewGroup.getChildCount(); ++i) { |
| View childView = viewGroup.getChildAt(i); |
| View focusedView = findAccessibilityFocus(childView); |
| if (focusedView != null) return focusedView; |
| } |
| return null; |
| } |
| |
| @Override |
| public void updateUserProfileOnLinkClick(String url, List<Long> entityMids) { |
| assert ThreadUtils.runningOnUiThread(); |
| long[] entityArray = new long[entityMids.size()]; |
| for (int i = 0; i < entityMids.size(); ++i) { |
| entityArray[i] = entityMids.get(i); |
| } |
| FeedStreamJni.get().updateUserProfileOnLinkClick( |
| mNativeFeedStream, mMakeGURL.apply(url), entityArray); |
| } |
| |
| @Override |
| public void updateWebFeedFollowState(WebFeedFollowUpdate update) { |
| byte[] webFeedId; |
| try { |
| webFeedId = update.webFeedName().getBytes("UTF8"); |
| } catch (UnsupportedEncodingException e) { |
| Log.i(TAG, "Invalid webFeedName", e); |
| return; |
| } |
| WebFeedFollowUpdate.Callback updateCallback = update.callback(); |
| if (update.isFollow()) { |
| Callback<WebFeedBridge.FollowResults> followCallback = results -> { |
| boolean successfulFollow = |
| results.requestStatus == WebFeedSubscriptionRequestStatus.SUCCESS; |
| if (updateCallback != null) { |
| updateCallback.requestComplete(successfulFollow); |
| } |
| if (successfulFollow && results.metadata != null) { |
| mWebFeedSnackbarController.showPostSuccessfulFollowHelp( |
| results.metadata.title, |
| results.metadata.availabilityStatus |
| == WebFeedAvailabilityStatus.ACTIVE, |
| mStreamKind, null /* tab */, null /* url */); |
| } |
| }; |
| WebFeedBridge.followFromId(webFeedId, update.isDurable(), |
| update.webFeedChangeReason(), followCallback); |
| } else { |
| WebFeedBridge.unfollow( |
| webFeedId, update.isDurable(), update.webFeedChangeReason(), results -> { |
| if (updateCallback != null) { |
| updateCallback.requestComplete(results.requestStatus |
| == WebFeedSubscriptionRequestStatus.SUCCESS); |
| } |
| }); |
| } |
| } |
| |
| @Override |
| public void openWebFeed(String webFeedName, @OpenWebFeedEntryPoint int entryPoint) { |
| @SingleWebFeedEntryPoint |
| int singleWebFeedEntryPoint; |
| |
| switch (entryPoint) { |
| case OpenWebFeedEntryPoint.ATTRIBUTION: |
| singleWebFeedEntryPoint = SingleWebFeedEntryPoint.ATTRIBUTION; |
| break; |
| case OpenWebFeedEntryPoint.RECOMMENDATION: |
| singleWebFeedEntryPoint = SingleWebFeedEntryPoint.RECOMMENDATION; |
| break; |
| case OpenWebFeedEntryPoint.GROUP_HEADER: |
| singleWebFeedEntryPoint = SingleWebFeedEntryPoint.GROUP_HEADER; |
| break; |
| |
| default: |
| singleWebFeedEntryPoint = SingleWebFeedEntryPoint.OTHER; |
| } |
| |
| mActionDelegate.openWebFeed(webFeedName, singleWebFeedEntryPoint); |
| } |
| |
| private void openSuggestionUrl( |
| String url, int disposition, boolean inGroup, OpenUrlOptions openOptions) { |
| boolean inNewTab = (disposition == WindowOpenDisposition.NEW_BACKGROUND_TAB |
| || disposition == WindowOpenDisposition.OFF_THE_RECORD); |
| |
| if (disposition != WindowOpenDisposition.NEW_BACKGROUND_TAB |
| && mReliabilityLogger != null) { |
| mReliabilityLogger.onOpenCard(); |
| } |
| |
| LoadUrlParams params = new LoadUrlParams(url, PageTransition.AUTO_BOOKMARK); |
| if (openOptions.shouldShowWebFeedAccelerator()) { |
| WebFeedRecommendationFollowAcceleratorController |
| .updateUrlParamsForRecommendedWebFeed( |
| params, openOptions.webFeedName().getBytes(StandardCharsets.UTF_8)); |
| } |
| |
| // This postTask is necessary so that other click-handlers have a chance |
| // to run before we begin navigating. On start surface, navigation immediately |
| // triggers unbind, which can break event handling. |
| PostTask.postTask(TaskTraits.UI_DEFAULT, () -> { |
| mActionDelegate.openSuggestionUrl(disposition, params, inGroup, /*onPageLoaded=*/ |
| () |
| -> FeedStreamJni.get().reportPageLoaded( |
| mNativeFeedStream, FeedStream.this, inNewTab), |
| visitResult |
| -> FeedServiceBridge.reportOpenVisitComplete(visitResult.visitTimeMs)); |
| }); |
| } |
| |
| @Override |
| public void showSyncConsentPrompt() { |
| mActionDelegate.showSyncConsentActivity(SigninAccessPoint.NTP_FEED_BOTTOM_PROMO); |
| } |
| |
| @Override |
| public void showSignInInterstitial() { |
| mActionDelegate.showSignInInterstitial(SigninAccessPoint.NTP_FEED_CARD_MENU_PROMO, |
| mBottomSheetController, mWindowAndroid); |
| } |
| } |
| |
| // Tracks in-progress work, primarily for work done by xsurface. |
| class InProgressWorkTracker { |
| private int mNextWorkId; |
| private final HashSet<Integer> mActiveWork = new HashSet<>(); |
| private final ObservableSupplierImpl<Boolean> mWorkPending = new ObservableSupplierImpl<>(); |
| |
| InProgressWorkTracker() { |
| // ObservableSupplierImpl holds null by default. |
| mWorkPending.set(false); |
| } |
| |
| /** |
| * Record that background work has begun, returns a runnable to be called when work is |
| * complete. |
| */ |
| Runnable addWork() { |
| int id = mNextWorkId++; |
| mActiveWork.add(id); |
| mWorkPending.set(true); |
| return () -> finishWork(id); |
| } |
| |
| /** postTask to call runnable after all in-progress work is complete. */ |
| void postTaskAfterWorkComplete(Runnable runnable) { |
| if (!mWorkPending.get()) { |
| PostTask.postTask(TaskTraits.UI_DEFAULT, runnable); |
| } else { |
| new DoneWatcher(runnable); |
| } |
| } |
| |
| /** Calls a runnable with postTask when mWorkPending is false. */ |
| private class DoneWatcher implements Callback<Boolean> { |
| private final Runnable mDelegate; |
| |
| DoneWatcher(Runnable runnable) { |
| mDelegate = runnable; |
| mWorkPending.addObserver(this); |
| } |
| |
| @Override |
| public void onResult(Boolean workPending) { |
| if (!workPending) { |
| PostTask.postTask(TaskTraits.UI_DEFAULT, mDelegate); |
| mWorkPending.removeObserver(this); |
| }; |
| } |
| } |
| private void finishWork(int workId) { |
| mActiveWork.remove(workId); |
| if (mActiveWork.isEmpty()) { |
| mWorkPending.set(false); |
| } |
| } |
| } |
| |
| /** |
| * Implementation of FeedActionsHandler methods. |
| */ |
| class FeedActionsHandlerImpl |
| implements org.chromium.chrome.browser.xsurface.FeedActionsHandler { |
| private static final int SNACKBAR_DURATION_MS_SHORT = 4000; |
| private static final int SNACKBAR_DURATION_MS_LONG = 10000; |
| // This is based on the menu animation time (218ms) from BottomSheet.java. |
| // It is private to an internal target, so we can't link, to it here. |
| private static final int MENU_DISMISS_TASK_DELAY = 318; |
| |
| @VisibleForTesting |
| static final String FEEDBACK_REPORT_TYPE = |
| "com.google.chrome.feed.USER_INITIATED_FEEDBACK_REPORT"; |
| @VisibleForTesting |
| static final String XSURFACE_CARD_URL = "Card URL"; |
| |
| @Override |
| public void processThereAndBackAgainData(byte[] data, LoggingParameters loggingParameters) { |
| assert ThreadUtils.runningOnUiThread(); |
| FeedStreamJni.get().processThereAndBackAgain(mNativeFeedStream, FeedStream.this, data, |
| FeedLoggingParameters.convertToProto(loggingParameters).toByteArray()); |
| } |
| |
| @Override |
| public void sendFeedback(Map<String, String> productSpecificDataMap) { |
| assert ThreadUtils.runningOnUiThread(); |
| FeedStreamJni.get().reportOtherUserAction( |
| mNativeFeedStream, FeedStream.this, FeedUserActionType.TAPPED_SEND_FEEDBACK); |
| |
| String url = productSpecificDataMap.get(XSURFACE_CARD_URL); |
| |
| // We want to hide the bottom sheet before sending feedback so the snapshot doesn't show |
| // the menu covering the article. However the menu is animating down, we need to wait |
| // for the animation to finish. We post a task to wait for the duration of the |
| // animation, then call send feedback. |
| |
| // FEEDBACK_REPORT_TYPE: Reports for Chrome mobile must have a contextTag of the form |
| // com.chrome.feed.USER_INITIATED_FEEDBACK_REPORT, or they will be discarded for not |
| // matching an allow list rule. |
| PostTask.postDelayedTask(TaskTraits.UI_DEFAULT, |
| () |
| -> mHelpAndFeedbackLauncher.showFeedback( |
| mActivity, url, FEEDBACK_REPORT_TYPE, productSpecificDataMap), |
| MENU_DISMISS_TASK_DELAY); |
| } |
| |
| @Override |
| public int requestDismissal(byte[] data) { |
| assert ThreadUtils.runningOnUiThread(); |
| return FeedStreamJni.get().executeEphemeralChange( |
| mNativeFeedStream, FeedStream.this, data); |
| } |
| |
| @Override |
| public void commitDismissal(int changeId) { |
| assert ThreadUtils.runningOnUiThread(); |
| FeedStreamJni.get().commitEphemeralChange(mNativeFeedStream, FeedStream.this, changeId); |
| |
| // Attempts to load more content if needed. |
| maybeLoadMore(); |
| } |
| |
| @Override |
| public void discardDismissal(int changeId) { |
| assert ThreadUtils.runningOnUiThread(); |
| FeedStreamJni.get().discardEphemeralChange( |
| mNativeFeedStream, FeedStream.this, changeId); |
| } |
| |
| private @org.chromium.chrome.browser.xsurface.feed.FeedActionsHandler.SnackbarDuration |
| int convertDuration(SnackbarDuration duration) { |
| switch (duration) { |
| case SHORT: |
| return org.chromium.chrome.browser.xsurface.feed.FeedActionsHandler |
| .SnackbarDuration.SHORT; |
| case LONG: |
| return org.chromium.chrome.browser.xsurface.feed.FeedActionsHandler |
| .SnackbarDuration.LONG; |
| } |
| return org.chromium.chrome.browser.xsurface.feed.FeedActionsHandler.SnackbarDuration |
| .SHORT; |
| } |
| |
| @Override |
| public void showSnackbar(String text, String actionLabel, SnackbarDuration duration, |
| SnackbarController controller) { |
| showSnackbar(text, actionLabel, convertDuration(duration), controller); |
| } |
| |
| @Override |
| public void showSnackbar(String text, String actionLabel, |
| @org.chromium.chrome.browser.xsurface.feed.FeedActionsHandler.SnackbarDuration |
| int duration, |
| org.chromium.chrome.browser.xsurface.feed.FeedActionsHandler |
| .SnackbarController delegateController) { |
| assert ThreadUtils.runningOnUiThread(); |
| int durationMs = SNACKBAR_DURATION_MS_SHORT; |
| if (duration |
| == org.chromium.chrome.browser.xsurface.feed.FeedActionsHandler.SnackbarDuration |
| .LONG) { |
| durationMs = SNACKBAR_DURATION_MS_LONG; |
| } |
| SnackbarManager.SnackbarController controller = |
| new SnackbarManager.SnackbarController() { |
| @Override |
| public void onAction(Object actionData) { |
| delegateController.onAction(mInProgressWorkTracker.addWork()); |
| } |
| @Override |
| public void onDismissNoAction(Object actionData) { |
| delegateController.onDismissNoAction(mInProgressWorkTracker.addWork()); |
| } |
| }; |
| |
| mSnackbarControllers.add(controller); |
| mSnackManager.showSnackbar(Snackbar.make(text, controller, Snackbar.TYPE_ACTION, |
| Snackbar.UMA_FEED_NTP_STREAM) |
| .setAction(actionLabel, /*actionData=*/null) |
| .setDuration(durationMs)); |
| } |
| |
| @Override |
| public void share(String url, String title) { |
| assert ThreadUtils.runningOnUiThread(); |
| mShareHelper.share(url, title); |
| FeedStreamJni.get().reportOtherUserAction( |
| mNativeFeedStream, FeedStream.this, FeedUserActionType.SHARE); |
| } |
| |
| @Override |
| public void openAutoplaySettings() { |
| assert ThreadUtils.runningOnUiThread(); |
| FeedStreamJni.get().reportOtherUserAction(mNativeFeedStream, FeedStream.this, |
| FeedUserActionType.OPENED_AUTOPLAY_SETTINGS); |
| mFeedAutoplaySettingsDelegate.launchAutoplaySettings(); |
| } |
| |
| @Override |
| public void watchForViewFirstVisible(View view, float viewedThreshold, Runnable runnable) { |
| assert ThreadUtils.runningOnUiThread(); |
| if (mSliceViewTracker != null) { |
| mSliceViewTracker.watchForFirstVisible( |
| getSliceIdFromView(view), viewedThreshold, runnable); |
| } |
| } |
| |
| @Override |
| public void reportInfoCardTrackViewStarted(int type) { |
| assert ThreadUtils.runningOnUiThread(); |
| FeedStreamJni.get().reportInfoCardTrackViewStarted( |
| mNativeFeedStream, FeedStream.this, type); |
| } |
| |
| @Override |
| public void reportInfoCardViewed(int type, int minimumViewIntervalSeconds) { |
| assert ThreadUtils.runningOnUiThread(); |
| FeedStreamJni.get().reportInfoCardViewed( |
| mNativeFeedStream, FeedStream.this, type, minimumViewIntervalSeconds); |
| } |
| |
| @Override |
| public void reportInfoCardClicked(int type) { |
| assert ThreadUtils.runningOnUiThread(); |
| FeedStreamJni.get().reportInfoCardClicked(mNativeFeedStream, FeedStream.this, type); |
| } |
| |
| @Override |
| public void reportInfoCardDismissedExplicitly(int type) { |
| assert ThreadUtils.runningOnUiThread(); |
| FeedStreamJni.get().reportInfoCardDismissedExplicitly( |
| mNativeFeedStream, FeedStream.this, type); |
| } |
| |
| @Override |
| public void resetInfoCardStates(int type) { |
| assert ThreadUtils.runningOnUiThread(); |
| FeedStreamJni.get().resetInfoCardStates(mNativeFeedStream, FeedStream.this, type); |
| } |
| |
| private @StreamType int feedIdentifierToType(@FeedIdentifier int fid) { |
| switch (fid) { |
| case FeedIdentifier.MAIN_FEED: |
| return StreamType.FOR_YOU; |
| case FeedIdentifier.FOLLOWING_FEED: |
| return StreamType.WEB_FEED; |
| } |
| return StreamType.UNSPECIFIED; |
| } |
| |
| @Override |
| public void invalidateContentCacheFor(@FeedIdentifier int feedToInvalidate) { |
| @StreamType |
| int feedKindToInvalidate = feedIdentifierToType(feedToInvalidate); |
| if (feedKindToInvalidate != StreamType.UNSPECIFIED) { |
| FeedStreamJni.get().invalidateContentCacheFor( |
| mNativeFeedStream, FeedStream.this, feedKindToInvalidate); |
| } |
| } |
| } |
| |
| private class RotationObserver implements DisplayAndroid.DisplayAndroidObserver { |
| /** |
| * If the device rotates, we dismiss the bottom sheet to avoid a bad interaction |
| * between the XSurface client and the chrome bottom sheet. |
| * |
| * @param rotation One of Surface.ROTATION_* values. |
| */ |
| @Override |
| public void onRotationChanged(int rotation) { |
| dismissBottomSheet(); |
| } |
| } |
| |
| // How far the user has to scroll down in DP before attempting to load more content. |
| private final int mLoadMoreTriggerScrollDistanceDp; |
| |
| private final Activity mActivity; |
| private final long mNativeFeedStream; |
| private final ObserverList<ContentChangedListener> mContentChangedListeners = |
| new ObserverList<>(); |
| private final int mStreamKind; |
| // Various helpers/controllers. |
| private ShareHelperWrapper mShareHelper; |
| private SnackbarManager mSnackManager; |
| private HelpAndFeedbackLauncher mHelpAndFeedbackLauncher; |
| private WindowAndroid mWindowAndroid; |
| private final FeedAutoplaySettingsDelegate mFeedAutoplaySettingsDelegate; |
| private UnreadContentObserver mUnreadContentObserver; |
| FeedContentFirstLoadWatcher mFeedContentFirstLoadWatcher; |
| private Stream.StreamsMediator mStreamsMediator; |
| // Snackbar (and post-Follow dialog) controller used exclusively for handling in-feed |
| // post-Follow and post-Unfollow UX. |
| WebFeedSnackbarController mWebFeedSnackbarController; |
| InProgressWorkTracker mInProgressWorkTracker = new InProgressWorkTracker(); |
| |
| // For loading more content. |
| private int mAccumulatedDySinceLastLoadMore; |
| private int mLoadMoreTriggerLookahead; |
| private boolean mIsLoadingMoreContent; |
| |
| // Things attached on bind. |
| private RestoreScrollObserver mRestoreScrollObserver = new RestoreScrollObserver(); |
| private RecyclerView.OnScrollListener mMainScrollListener; |
| private FeedSliceViewTracker mSliceViewTracker; |
| private ScrollReporter mScrollReporter; |
| private final Map<String, Object> mHandlersMap; |
| private RotationObserver mRotationObserver; |
| private FeedReliabilityLoggingBridge mReliabilityLoggingBridge; |
| private @Nullable FeedReliabilityLogger mReliabilityLogger; |
| |
| // Things valid only when bound. |
| private @Nullable RecyclerView mRecyclerView; |
| private @Nullable FeedListContentManager mContentManager; |
| private @Nullable SurfaceScope mSurfaceScope; |
| private @Nullable HybridListRenderer mRenderer; |
| private FeedScrollState mScrollStateToRestore; |
| private int mHeaderCount; |
| private boolean mIsPlaceholderShown; |
| private long mLastFetchTimeMs; |
| private ArrayList<SnackbarManager.SnackbarController> mSnackbarControllers = new ArrayList<>(); |
| |
| // Placeholder view that simply takes up space. |
| private FeedListContentManager.NativeViewContent mSpacerViewContent; |
| |
| // Bottomsheet. |
| private final BottomSheetController mBottomSheetController; |
| private BottomSheetContent mBottomSheetContent; |
| private String mBottomSheetOriginatingSliceId; |
| private View mLastFocusedView; |
| private View mLastAccessibilityFocusedView; |
| |
| /** |
| * Creates a new Feed Stream. |
| * @param activity {@link Activity} that this is bound to. |
| * @param snackbarManager {@link SnackbarManager} for showing snackbars. |
| * @param bottomSheetController {@link BottomSheetController} for menus. |
| * @param isPlaceholderShown Whether the placeholder is shown initially. |
| * @param windowAndroid The {@link WindowAndroid} this is shown on. |
| * @param shareDelegateSupplier The supplier for {@link ShareDelegate} for sharing actions. |
| * @param streamKind Kind of stream data this feed stream serves. |
| * @param feedAutoplaySettingsDelegate The delegate to invoke autoplay settings. |
| * @param actionDelegate Implements some Feed actions. |
| * @param helpAndFeedbackLauncher A HelpAndFeedbackLauncher. |
| * @param feedContentFirstLoadWatcher a listener for events about feed loading. |
| * @param streamsMediator the mediator for multiple streams. |
| * @param singleWebFeedParameters the parameters needed to create a single web feed. |
| */ |
| public FeedStream(Activity activity, SnackbarManager snackbarManager, |
| BottomSheetController bottomSheetController, boolean isPlaceholderShown, |
| WindowAndroid windowAndroid, Supplier<ShareDelegate> shareDelegateSupplier, |
| int streamKind, FeedAutoplaySettingsDelegate feedAutoplaySettingsDelegate, |
| FeedActionDelegate actionDelegate, HelpAndFeedbackLauncher helpAndFeedbackLauncher, |
| FeedContentFirstLoadWatcher feedContentFirstLoadWatcher, |
| Stream.StreamsMediator streamsMediator, |
| SingleWebFeedParameters singleWebFeedParameters) { |
| mActivity = activity; |
| mStreamKind = streamKind; |
| mReliabilityLoggingBridge = new FeedReliabilityLoggingBridge(); |
| if (streamKind == StreamKind.SINGLE_WEB_FEED) { |
| mNativeFeedStream = |
| FeedStreamJni.get().initWebFeed(this, singleWebFeedParameters.getWebFeedId(), |
| mReliabilityLoggingBridge.getNativePtr(), |
| singleWebFeedParameters.getEntryPoint()); |
| } else { |
| mNativeFeedStream = FeedStreamJni.get().init( |
| this, streamKind, mReliabilityLoggingBridge.getNativePtr()); |
| } |
| mBottomSheetController = bottomSheetController; |
| mShareHelper = new ShareHelperWrapper(windowAndroid, shareDelegateSupplier); |
| mSnackManager = snackbarManager; |
| mHelpAndFeedbackLauncher = helpAndFeedbackLauncher; |
| mIsPlaceholderShown = isPlaceholderShown; |
| mWindowAndroid = windowAndroid; |
| mFeedAutoplaySettingsDelegate = feedAutoplaySettingsDelegate; |
| mRotationObserver = new RotationObserver(); |
| mFeedContentFirstLoadWatcher = feedContentFirstLoadWatcher; |
| mStreamsMediator = streamsMediator; |
| WebFeedSnackbarController.FeedLauncher snackbarAction; |
| if (mStreamKind == StreamKind.FOLLOWING) { |
| snackbarAction = () -> { |
| mStreamsMediator.refreshStream(); |
| }; |
| } else { |
| snackbarAction = () -> { |
| mStreamsMediator.switchToStreamKind(StreamKind.FOLLOWING); |
| }; |
| } |
| mWebFeedSnackbarController = new WebFeedSnackbarController( |
| activity, snackbarAction, windowAndroid.getModalDialogManager(), snackbarManager); |
| |
| mHandlersMap = new HashMap<>(); |
| mHandlersMap.put(SurfaceActionsHandler.KEY, new FeedSurfaceActionsHandler(actionDelegate)); |
| mHandlersMap.put(FeedActionsHandler.KEY, new FeedActionsHandlerImpl()); |
| |
| this.mLoadMoreTriggerScrollDistanceDp = |
| FeedServiceBridge.getLoadMoreTriggerScrollDistanceDp(); |
| |
| addOnContentChangedListener(contents -> { |
| // Feed's background is set to be transparent in {@link #bind} to show the Feed |
| // placeholder. When first batch of articles are about to show, set recyclerView back |
| // to non-transparent. |
| if (isPlaceholderShown()) { |
| hidePlaceholder(); |
| } |
| }); |
| mScrollReporter = new ScrollReporter(); |
| |
| mLoadMoreTriggerLookahead = FeedServiceBridge.getLoadMoreTriggerLookahead(); |
| |
| mMainScrollListener = new RecyclerView.OnScrollListener() { |
| @Override |
| public void onScrolled(RecyclerView v, int dx, int dy) { |
| super.onScrolled(v, dx, dy); |
| checkScrollingForLoadMore(dy); |
| FeedStreamJni.get().reportStreamScrollStart(mNativeFeedStream, FeedStream.this); |
| mScrollReporter.trackScroll(dx, dy); |
| } |
| }; |
| |
| // Only watch for unread content on the web feed, not for-you feed. |
| // Sort options only available for web feed right now. |
| if (streamKind == StreamKind.FOLLOWING) { |
| mUnreadContentObserver = new UnreadContentObserver(/*isWebFeed=*/true); |
| } |
| } |
| |
| @Override |
| public boolean supportsOptions() { |
| return ChromeFeatureList.isEnabled(ChromeFeatureList.WEB_FEED_SORT) |
| && mStreamKind == StreamKind.FOLLOWING; |
| } |
| |
| @Override |
| public void destroy() { |
| if (mUnreadContentObserver != null) { |
| mUnreadContentObserver.destroy(); |
| } |
| mReliabilityLoggingBridge.destroy(); |
| } |
| |
| @Override |
| @StreamKind |
| public int getStreamKind() { |
| return mStreamKind; |
| } |
| |
| @Override |
| public String getContentState() { |
| return String.valueOf(mLastFetchTimeMs); |
| } |
| |
| @Override |
| public void bind(RecyclerView rootView, FeedListContentManager manager, |
| FeedScrollState savedInstanceState, SurfaceScope surfaceScope, |
| HybridListRenderer renderer, @Nullable FeedReliabilityLogger reliabilityLogger, |
| int headerCount) { |
| mReliabilityLogger = reliabilityLogger; |
| if (mReliabilityLogger != null) { |
| mReliabilityLogger.onBindStream(getStreamType(), |
| FeedStreamJni.get().getSurfaceId(mNativeFeedStream, FeedStream.this)); |
| } |
| mReliabilityLoggingBridge.setLogger(mReliabilityLogger); |
| |
| mScrollStateToRestore = savedInstanceState; |
| manager.setHandlers(mHandlersMap); |
| mSliceViewTracker = new FeedSliceViewTracker(rootView, mActivity, manager, |
| renderer.getListLayoutHelper(), /* watchForBarelyVisibleChange= */ |
| (mReliabilityLogger != null |
| && mReliabilityLogger.getUserInteractionLogger() != null), |
| new FeedStream.ViewTrackerObserver()); |
| mSliceViewTracker.bind(); |
| |
| rootView.addOnScrollListener(mMainScrollListener); |
| rootView.getAdapter().registerAdapterDataObserver(mRestoreScrollObserver); |
| mRecyclerView = rootView; |
| mContentManager = manager; |
| mSurfaceScope = surfaceScope; |
| mRenderer = renderer; |
| mHeaderCount = headerCount; |
| if (mWindowAndroid.getDisplay() != null) { |
| mWindowAndroid.getDisplay().addObserver(mRotationObserver); |
| } |
| |
| if (isPlaceholderShown()) { |
| // Set recyclerView as transparent until first batch of articles are loaded. Before |
| // that, the placeholder is shown. |
| mRecyclerView.getBackground().setAlpha(0); |
| } |
| |
| FeedStreamJni.get().surfaceOpened(mNativeFeedStream, FeedStream.this); |
| } |
| |
| @Override |
| public void restoreSavedInstanceState(FeedScrollState scrollState) { |
| if (!restoreScrollState(scrollState)) { |
| mScrollStateToRestore = scrollState; |
| } |
| } |
| |
| // Dismiss any snackbars. Note that dismissal of snackbars sometimes triggers work in |
| // xsurface. |
| private void dismissSnackbars() { |
| for (SnackbarManager.SnackbarController controller : mSnackbarControllers) { |
| mSnackManager.dismissSnackbars(controller); |
| } |
| } |
| |
| @Override |
| public void unbind(boolean shouldPlaceSpacer) { |
| // This is the catch-all feed launch end event to ensure a complete flow is logged |
| // even if we don't know a more specific reason for the stream unbinding. |
| if (mReliabilityLogger != null) { |
| mReliabilityLogger.onUnbindStream(); |
| } |
| |
| dismissSnackbars(); |
| mSnackbarControllers.clear(); |
| mWebFeedSnackbarController.dismissSnackbars(); |
| |
| mSliceViewTracker.destroy(); |
| mSliceViewTracker = null; |
| mSurfaceScope = null; |
| mAccumulatedDySinceLastLoadMore = 0; |
| mScrollReporter.onUnbind(); |
| |
| // Remove Feed content from the content manager. Add spacer if needed. |
| ArrayList<FeedListContentManager.FeedContent> list = new ArrayList<>(); |
| if (shouldPlaceSpacer) { |
| addSpacer(list); |
| } |
| updateContentsInPlace(list); |
| |
| // Dismiss bottomsheet if any is shown. |
| dismissBottomSheet(); |
| |
| // Clear handlers. |
| mContentManager.setHandlers(new HashMap<>()); |
| mContentManager = null; |
| |
| mRecyclerView.removeOnScrollListener(mMainScrollListener); |
| mRecyclerView.getAdapter().unregisterAdapterDataObserver(mRestoreScrollObserver); |
| mRecyclerView = null; |
| |
| if (mWindowAndroid.getDisplay() != null) { |
| mWindowAndroid.getDisplay().removeObserver(mRotationObserver); |
| } |
| |
| FeedStreamJni.get().surfaceClosed(mNativeFeedStream, FeedStream.this); |
| } |
| |
| @Override |
| public void notifyNewHeaderCount(int newHeaderCount) { |
| mHeaderCount = newHeaderCount; |
| } |
| |
| @Override |
| public void addOnContentChangedListener(ContentChangedListener listener) { |
| mContentChangedListeners.addObserver(listener); |
| } |
| |
| @Override |
| public void removeOnContentChangedListener(ContentChangedListener listener) { |
| mContentChangedListeners.removeObserver(listener); |
| } |
| |
| @Override |
| public void triggerRefresh(Callback<Boolean> callback) { |
| dismissSnackbars(); |
| mInProgressWorkTracker.postTaskAfterWorkComplete(() -> { |
| if (mRenderer != null) { |
| mRenderer.onPullToRefreshStarted(); |
| } |
| FeedStreamJni.get().manualRefresh(mNativeFeedStream, FeedStream.this, callback); |
| }); |
| } |
| |
| @Override |
| public boolean isPlaceholderShown() { |
| return mIsPlaceholderShown; |
| } |
| |
| @Override |
| public void hidePlaceholder() { |
| if (!mIsPlaceholderShown || mContentManager == null) { |
| return; |
| } |
| ObjectAnimator animator = ObjectAnimator.ofPropertyValuesHolder( |
| mRecyclerView.getBackground(), PropertyValuesHolder.ofInt("alpha", 255)); |
| animator.setTarget(mRecyclerView.getBackground()); |
| animator.setDuration(mRecyclerView.getItemAnimator().getAddDuration()) |
| .setInterpolator(Interpolators.LINEAR_INTERPOLATOR); |
| animator.start(); |
| mIsPlaceholderShown = false; |
| } |
| |
| void dismissBottomSheet() { |
| assert ThreadUtils.runningOnUiThread(); |
| if (mBottomSheetContent != null) { |
| mBottomSheetController.hideContent(mBottomSheetContent, true); |
| } |
| mBottomSheetContent = null; |
| mBottomSheetOriginatingSliceId = null; |
| } |
| |
| @CalledByNative |
| void replaceDataStoreEntry(String key, byte[] data) { |
| if (mSurfaceScope != null) mSurfaceScope.replaceDataStoreEntry(key, data); |
| } |
| |
| @CalledByNative |
| void removeDataStoreEntry(String key) { |
| if (mSurfaceScope != null) mSurfaceScope.removeDataStoreEntry(key); |
| } |
| |
| @VisibleForTesting |
| void checkScrollingForLoadMore(int dy) { |
| if (mContentManager == null) return; |
| |
| mAccumulatedDySinceLastLoadMore += dy; |
| if (mAccumulatedDySinceLastLoadMore < 0) { |
| mAccumulatedDySinceLastLoadMore = 0; |
| } |
| if (mAccumulatedDySinceLastLoadMore < TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, |
| mLoadMoreTriggerScrollDistanceDp, |
| mRecyclerView.getResources().getDisplayMetrics())) { |
| return; |
| } |
| |
| boolean canTrigger = maybeLoadMore(); |
| if (canTrigger) { |
| mAccumulatedDySinceLastLoadMore = 0; |
| } |
| } |
| |
| @Override |
| public ObservableSupplier<Boolean> hasUnreadContent() { |
| return mUnreadContentObserver != null ? mUnreadContentObserver.mHasUnreadContent |
| : Stream.super.hasUnreadContent(); |
| } |
| |
| @Override |
| public long getLastFetchTimeMs() { |
| return FeedStreamJni.get().getLastFetchTimeMs(mNativeFeedStream, FeedStream.this); |
| } |
| |
| /** |
| * Attempts to load more content if it can be triggered. |
| * |
| * <p>This method uses the default or Finch configured load more lookahead trigger. |
| * |
| * @return true if loading more content can be triggered. |
| */ |
| boolean maybeLoadMore() { |
| return maybeLoadMore(mLoadMoreTriggerLookahead); |
| } |
| |
| /** |
| * Attempts to load more content if it can be triggered. |
| * @param lookaheadTrigger The threshold of off-screen cards below which the feed should attempt |
| * to load more content. I.e., if there are less than or equal to |lookaheadTrigger| |
| * cards left to show the user, then the feed should load more cards. |
| * @return true if loading more content can be triggered. |
| */ |
| private boolean maybeLoadMore(int lookaheadTrigger) { |
| // Checks if we've been unbinded. |
| if (mRecyclerView == null) { |
| return false; |
| } |
| // Checks if loading more can be triggered. |
| LayoutManager layoutManager = mRecyclerView.getLayoutManager(); |
| if (layoutManager == null) { |
| return false; |
| } |
| |
| // Check if the layout manager is initialized. |
| int totalItemCount = layoutManager.getItemCount(); |
| if (totalItemCount < 0) { |
| return false; |
| } |
| |
| // When swapping feeds, the totalItemCount and lastVisibleItemPosition can temporarily fall |
| // out of sync. Early exit on the pathological case where we think we're showing an item |
| // beyond the end of the feed. This can occur if maybeLoadMore() is called during a feed |
| // swap, after the feed items have been cleared, but before the view has finished updating |
| // (which happens asynchronously). |
| int lastVisibleItem = mRenderer.getListLayoutHelper().findLastVisibleItemPosition(); |
| if (totalItemCount < lastVisibleItem) { |
| return false; |
| } |
| |
| // No need to load more if there are more scrollable items than the trigger amount. |
| int numItemsRemaining = totalItemCount - lastVisibleItem; |
| if (numItemsRemaining > lookaheadTrigger) { |
| return false; |
| } |
| |
| // Starts to load more content if not yet. |
| if (!mIsLoadingMoreContent) { |
| mIsLoadingMoreContent = true; |
| FeedUma.recordFeedLoadMoreTrigger(getStreamKind(), totalItemCount, numItemsRemaining); |
| // The native loadMore() call may immediately result in onStreamUpdated(), which can |
| // result in a crash if maybeLoadMore() is being called in response to certain events. |
| // Use postTask to avoid this. |
| PostTask.postTask(TaskTraits.UI_DEFAULT, |
| () |
| -> FeedStreamJni.get().loadMore(mNativeFeedStream, FeedStream.this, |
| (Boolean success) -> { mIsLoadingMoreContent = false; })); |
| } |
| |
| return true; |
| } |
| |
| /** |
| * Adds a spacer into the recycler view at the current position. If there is no spacer, we can't |
| * scroll to the top, the scrolling code won't go past the end of the content. |
| */ |
| void addSpacer(List list) { |
| if (mSpacerViewContent == null) { |
| DisplayMetrics displayMetrics = new DisplayMetrics(); |
| mActivity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); |
| FrameLayout spacerView = new FrameLayout(mActivity); |
| mSpacerViewContent = new FeedListContentManager.NativeViewContent( |
| getLateralPaddingsPx(), SPACER_KEY, spacerView); |
| spacerView.setLayoutParams(new FrameLayout.LayoutParams( |
| ViewGroup.LayoutParams.MATCH_PARENT, displayMetrics.heightPixels)); |
| } |
| list.add(mSpacerViewContent); |
| } |
| |
| /** Called when the stream update content is available. The content will get passed to UI */ |
| @CalledByNative |
| void onStreamUpdated(byte[] data) { |
| // There should be no updates while the surface is closed. If the surface was recently |
| // closed, just ignore these. |
| if (mContentManager == null) return; |
| FeedUiProto.StreamUpdate streamUpdate; |
| try { |
| streamUpdate = FeedUiProto.StreamUpdate.parseFrom(data); |
| } catch (com.google.protobuf.InvalidProtocolBufferException e) { |
| Log.wtf(TAG, "Unable to parse StreamUpdate proto data", e); |
| mReliabilityLoggingBridge.onStreamUpdateError(); |
| return; |
| } |
| |
| mLastFetchTimeMs = streamUpdate.getFetchTimeMs(); |
| |
| FeedLoggingParameters loggingParameters = |
| new FeedLoggingParameters(streamUpdate.getLoggingParameters()); |
| |
| // Invalidate the saved scroll state if the content in the feed has changed. |
| // Don't do anything if mLastFetchTimeMs is unset. |
| if (mScrollStateToRestore != null && mLastFetchTimeMs != 0) { |
| if (!mScrollStateToRestore.feedContentState.equals(getContentState())) { |
| mScrollStateToRestore = null; |
| } |
| } |
| |
| // Update using shared states. |
| for (FeedUiProto.SharedState state : streamUpdate.getNewSharedStatesList()) { |
| mRenderer.update(state.getXsurfaceSharedState().toByteArray()); |
| } |
| |
| boolean foundNewContent = false; |
| |
| // Builds the new list containing: |
| // * existing headers |
| // * both new and existing contents |
| ArrayList<FeedListContentManager.FeedContent> newContentList = new ArrayList<>(); |
| for (FeedUiProto.StreamUpdate.SliceUpdate sliceUpdate : |
| streamUpdate.getUpdatedSlicesList()) { |
| if (sliceUpdate.hasSlice()) { |
| FeedListContentManager.FeedContent content = |
| createContentFromSlice(sliceUpdate.getSlice(), loggingParameters); |
| if (content != null) { |
| newContentList.add(content); |
| if (!content.isNativeView()) { |
| foundNewContent = true; |
| } |
| } |
| } else { |
| String existingSliceId = sliceUpdate.getSliceId(); |
| int position = mContentManager.findContentPositionByKey(existingSliceId); |
| if (position != -1) { |
| newContentList.add(mContentManager.getContent(position)); |
| if (!mContentManager.getContent(position).isNativeView()) { |
| foundNewContent = true; |
| } |
| } |
| // We intentionially don't add the spacer back in. The spacer has a key SPACER_KEY, |
| // not a slice id. |
| } |
| } |
| |
| updateContentsInPlace(newContentList); |
| mRecyclerView.post(mReliabilityLoggingBridge::onStreamUpdateFinished); |
| |
| // If we have new content, and the new content callback is set, then call it, and clear the |
| // callback. |
| if (mFeedContentFirstLoadWatcher != null && foundNewContent) { |
| mFeedContentFirstLoadWatcher.nonNativeContentLoaded(mStreamKind); |
| mFeedContentFirstLoadWatcher = null; |
| } |
| |
| // If all of the cards fit on the screen, load more content. The view |
| // may not be scrollable, preventing the user from otherwise triggering |
| // load more. |
| maybeLoadMore(/*lookaheadTrigger=*/0); |
| } |
| |
| private FeedListContentManager.FeedContent createContentFromSlice( |
| FeedUiProto.Slice slice, LoggingParameters loggingParameters) { |
| String sliceId = slice.getSliceId(); |
| if (slice.hasXsurfaceSlice()) { |
| return new FeedListContentManager.ExternalViewContent(sliceId, |
| slice.getXsurfaceSlice().getXsurfaceFrame().toByteArray(), loggingParameters); |
| } else if (slice.hasLoadingSpinnerSlice()) { |
| // If the placeholder is shown, spinner is not needed. |
| if (mIsPlaceholderShown) { |
| return null; |
| } |
| if (ChromeFeatureList.isEnabled(ChromeFeatureList.FEED_LOADING_PLACEHOLDER) |
| && slice.getLoadingSpinnerSlice().getIsAtTop()) { |
| return new FeedListContentManager.NativeViewContent( |
| getLateralPaddingsPx(), sliceId, R.layout.feed_placeholder_layout); |
| } |
| return new FeedListContentManager.NativeViewContent( |
| getLateralPaddingsPx(), sliceId, R.layout.feed_spinner); |
| } |
| assert slice.hasZeroStateSlice(); |
| if (mStreamKind == StreamKind.FOLLOWING) { |
| return new FeedListContentManager.NativeViewContent( |
| getLateralPaddingsPx(), sliceId, R.layout.following_empty_state); |
| } |
| if (mStreamKind == StreamKind.SINGLE_WEB_FEED) { |
| View creatorErrorCard; |
| // TODO(crbug/1396161): Add offline error scenario. |
| if (slice.getZeroStateSlice().getType() |
| == FeedUiProto.ZeroStateSlice.Type.NO_CARDS_AVAILABLE) { |
| creatorErrorCard = LayoutInflater.from(mActivity).inflate( |
| R.layout.creator_content_unavailable_error, mRecyclerView, false); |
| } else { |
| mStreamsMediator.disableFollowButton(); |
| creatorErrorCard = LayoutInflater.from(mActivity).inflate( |
| R.layout.creator_general_error, mRecyclerView, false); |
| } |
| // TODO(crbug/1385903): Replace display height dependency with setting the |
| // RecyclerView height to match_parent. |
| DisplayMetrics displayMetrics = new DisplayMetrics(); |
| mActivity.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); |
| MarginLayoutParams marginParams = |
| (MarginLayoutParams) creatorErrorCard.getLayoutParams(); |
| marginParams.setMargins(0, displayMetrics.heightPixels / 4, 0, |
| mActivity.getResources().getDimensionPixelSize( |
| R.dimen.creator_error_margin_bottom)); |
| return new FeedListContentManager.NativeViewContent( |
| getLateralPaddingsPx(), sliceId, creatorErrorCard); |
| } |
| if (slice.getZeroStateSlice().getType() == FeedUiProto.ZeroStateSlice.Type.CANT_REFRESH) { |
| return new FeedListContentManager.NativeViewContent( |
| getLateralPaddingsPx(), sliceId, R.layout.no_connection); |
| } |
| // TODO(crbug/1152592): Add new UI for NO_WEB_FEED_SUBSCRIPTIONS. |
| assert slice.getZeroStateSlice().getType() |
| == FeedUiProto.ZeroStateSlice.Type.NO_CARDS_AVAILABLE |
| || slice.getZeroStateSlice().getType() |
| == FeedUiProto.ZeroStateSlice.Type.NO_WEB_FEED_SUBSCRIPTIONS; |
| return new FeedListContentManager.NativeViewContent( |
| getLateralPaddingsPx(), sliceId, R.layout.no_content_v2); |
| } |
| |
| private void updateContentsInPlace( |
| ArrayList<FeedListContentManager.FeedContent> newContentList) { |
| assert mHeaderCount <= mContentManager.getItemCount(); |
| if (mContentManager.replaceRange( |
| mHeaderCount, mContentManager.getItemCount() - mHeaderCount, newContentList)) { |
| notifyContentChange(); |
| } |
| } |
| |
| private @StreamType int getStreamType() { |
| switch (mStreamKind) { |
| case StreamKind.FOR_YOU: |
| return StreamType.FOR_YOU; |
| case StreamKind.FOLLOWING: |
| return StreamType.WEB_FEED; |
| case StreamKind.SINGLE_WEB_FEED: |
| return StreamType.SINGLE_WEB_FEED; |
| default: |
| return StreamType.UNSPECIFIED; |
| } |
| } |
| |
| /** |
| * Restores the scroll state serialized to |savedInstanceState|. |
| * @return true if the scroll state was restored, or if the state could never be restored. |
| * false if we need to wait until more items are added to the recycler view to make it |
| * scrollable. |
| */ |
| private boolean restoreScrollState(FeedScrollState state) { |
| assert (mRecyclerView != null); |
| if (state == null || state.lastPosition < 0 || state.position < 0) return true; |
| |
| // If too few items exist, defer scrolling until later. |
| if (mContentManager.getItemCount() <= state.lastPosition) return false; |
| // Don't try to resume scrolling to a native view. This avoids scroll resume to the refresh |
| // spinner. |
| if (mContentManager.getContent(state.lastPosition).isNativeView()) return false; |
| |
| ListLayoutHelper layoutHelper = mRenderer.getListLayoutHelper(); |
| if (layoutHelper != null) { |
| layoutHelper.scrollToPositionWithOffset(state.position, state.offset); |
| } |
| return true; |
| } |
| |
| private void notifyContentChange() { |
| for (ContentChangedListener listener : mContentChangedListeners) { |
| listener.onContentChanged( |
| mContentManager != null ? mContentManager.getContentList() : null); |
| } |
| } |
| |
| @VisibleForTesting |
| String getSliceIdFromView(View view) { |
| View childOfRoot = findChildViewContainingDescendant(mRecyclerView, view); |
| |
| if (childOfRoot != null) { |
| // View is a child of the recycler view, find slice using the index. |
| int position = mRecyclerView.getChildAdapterPosition(childOfRoot); |
| if (position >= 0 && position < mContentManager.getItemCount()) { |
| return mContentManager.getContent(position).getKey(); |
| } |
| } else if (mBottomSheetContent != null |
| && findChildViewContainingDescendant(mBottomSheetContent.getContentView(), view) |
| != null) { |
| // View is a child of the bottom sheet, return slice associated with the bottom |
| // sheet. |
| return mBottomSheetOriginatingSliceId; |
| } |
| return ""; |
| } |
| |
| /** |
| * Returns the immediate child of parentView which contains descendantView. |
| * If descendantView is not in parentView's view hierarchy, this returns null. |
| * Note that the returned view may be descendantView, or descendantView.getParent(), |
| * or descendantView.getParent().getParent(), etc... |
| */ |
| private View findChildViewContainingDescendant(View parentView, View descendantView) { |
| if (parentView == null || descendantView == null) return null; |
| // Find the direct child of parentView which owns view. |
| if (parentView == descendantView.getParent()) { |
| return descendantView; |
| } else { |
| // One of the view's ancestors might be the child. |
| ViewParent p = descendantView.getParent(); |
| while (true) { |
| if (p == null) { |
| return null; |
| } |
| if (p.getParent() == parentView) { |
| if (p instanceof View) return (View) p; |
| return null; |
| } |
| p = p.getParent(); |
| } |
| } |
| } |
| |
| @VisibleForTesting |
| void setHelpAndFeedbackLauncherForTest(HelpAndFeedbackLauncher launcher) { |
| mHelpAndFeedbackLauncher = launcher; |
| } |
| |
| @VisibleForTesting |
| void setShareWrapperForTest(ShareHelperWrapper shareWrapper) { |
| mShareHelper = shareWrapper; |
| } |
| |
| /** @returns True if this feed has been bound. */ |
| @VisibleForTesting |
| public boolean getBoundStatusForTest() { |
| return mContentManager != null; |
| } |
| |
| @VisibleForTesting |
| RecyclerView.OnScrollListener getScrollListenerForTest() { |
| return mMainScrollListener; |
| } |
| |
| @VisibleForTesting |
| UnreadContentObserver getUnreadContentObserverForTest() { |
| return mUnreadContentObserver; |
| } |
| |
| InProgressWorkTracker getInProgressWorkTrackerForTesting() { |
| return mInProgressWorkTracker; |
| } |
| |
| // Scroll state can't be restored until enough items are added to the recycler view adapter. |
| // Attempts to restore scroll state every time new items are added to the adapter. |
| class RestoreScrollObserver extends RecyclerView.AdapterDataObserver { |
| @Override |
| public void onItemRangeInserted(int positionStart, int itemCount) { |
| if (mScrollStateToRestore != null) { |
| if (restoreScrollState(mScrollStateToRestore)) { |
| mScrollStateToRestore = null; |
| } |
| } |
| } |
| }; |
| |
| private class ViewTrackerObserver implements FeedSliceViewTracker.Observer { |
| @Override |
| public void sliceVisible(String sliceId) { |
| FeedStreamJni.get().reportSliceViewed(mNativeFeedStream, FeedStream.this, sliceId); |
| } |
| @Override |
| public void reportContentSliceVisibleTime(long elapsedMs) { |
| FeedStreamJni.get().reportContentSliceVisibleTimeForGoodVisits( |
| mNativeFeedStream, FeedStream.this, elapsedMs); |
| } |
| @Override |
| public void feedContentVisible() { |
| FeedStreamJni.get().reportFeedViewed(mNativeFeedStream, FeedStream.this); |
| } |
| @Override |
| public void reportViewFirstBarelyVisible(View view) { |
| if (mReliabilityLogger != null) { |
| mReliabilityLogger.onViewFirstVisible(view); |
| } |
| } |
| @Override |
| public void reportViewFirstRendered(View view) { |
| if (mReliabilityLogger != null) { |
| mReliabilityLogger.onViewFirstRendered(view); |
| } |
| } |
| } |
| |
| /** |
| * Provides a wrapper around sharing methods. |
| * |
| * Makes it easier to test. |
| */ |
| @VisibleForTesting |
| static class ShareHelperWrapper { |
| private WindowAndroid mWindowAndroid; |
| private Supplier<ShareDelegate> mShareDelegateSupplier; |
| public ShareHelperWrapper( |
| WindowAndroid windowAndroid, Supplier<ShareDelegate> shareDelegateSupplier) { |
| mWindowAndroid = windowAndroid; |
| mShareDelegateSupplier = shareDelegateSupplier; |
| } |
| |
| /** |
| * Shares a url and title from Chrome to another app. |
| * Brings up the share sheet. |
| */ |
| public void share(String url, String title) { |
| ShareParams params = new ShareParams.Builder(mWindowAndroid, title, url).build(); |
| mShareDelegateSupplier.get().share(params, new ChromeShareExtras.Builder().build(), |
| ShareDelegate.ShareOrigin.FEED); |
| } |
| } |
| |
| // Ingests scroll events and reports scroll completion back to native. |
| private class ScrollReporter extends ScrollTracker { |
| @Override |
| protected void onScrollEvent(int scrollAmount) { |
| FeedStreamJni.get().reportStreamScrolled( |
| mNativeFeedStream, FeedStream.this, scrollAmount); |
| } |
| } |
| |
| @VisibleForTesting |
| class UnreadContentObserver extends FeedServiceBridge.UnreadContentObserver { |
| ObservableSupplierImpl<Boolean> mHasUnreadContent = new ObservableSupplierImpl<>(); |
| |
| UnreadContentObserver(boolean isWebFeed) { |
| super(isWebFeed); |
| mHasUnreadContent.set(false); |
| } |
| |
| @Override |
| public void hasUnreadContentChanged(boolean hasUnreadContent) { |
| mHasUnreadContent.set(hasUnreadContent); |
| } |
| } |
| |
| private int getLateralPaddingsPx() { |
| return mActivity.getResources().getDimensionPixelSize( |
| R.dimen.ntp_header_lateral_paddings_v2); |
| } |
| |
| @NativeMethods |
| @VisibleForTesting |
| public interface Natives { |
| long init(FeedStream caller, @StreamKind int streamKind, |
| long nativeFeedReliabilityLoggingBridge); |
| long initWebFeed(FeedStream caller, byte[] webFeedId, |
| long nativeFeedReliabilityLoggingBridge, int entryPoint); |
| void reportFeedViewed(long nativeFeedStream, FeedStream caller); |
| void reportSliceViewed(long nativeFeedStream, FeedStream caller, String sliceId); |
| void reportPageLoaded(long nativeFeedStream, FeedStream caller, boolean inNewTab); |
| void reportOpenAction(long nativeFeedStream, FeedStream caller, GURL url, String sliceId, |
| @OpenActionType int openActionType); |
| void reportOtherUserAction( |
| long nativeFeedStream, FeedStream caller, @FeedUserActionType int userAction); |
| void reportStreamScrolled(long nativeFeedStream, FeedStream caller, int distanceDp); |
| void reportStreamScrollStart(long nativeFeedStream, FeedStream caller); |
| void updateUserProfileOnLinkClick(long nativeFeedStream, GURL url, long[] mids); |
| void loadMore(long nativeFeedStream, FeedStream caller, Callback<Boolean> callback); |
| void manualRefresh(long nativeFeedStream, FeedStream caller, Callback<Boolean> callback); |
| void processThereAndBackAgain( |
| long nativeFeedStream, FeedStream caller, byte[] data, byte[] loggingParameters); |
| int executeEphemeralChange(long nativeFeedStream, FeedStream caller, byte[] data); |
| void commitEphemeralChange(long nativeFeedStream, FeedStream caller, int changeId); |
| void discardEphemeralChange(long nativeFeedStream, FeedStream caller, int changeId); |
| void surfaceOpened(long nativeFeedStream, FeedStream caller); |
| void surfaceClosed(long nativeFeedStream, FeedStream caller); |
| int getSurfaceId(long nativeFeedStream, FeedStream caller); |
| long getLastFetchTimeMs(long nativeFeedStream, FeedStream caller); |
| void reportInfoCardTrackViewStarted(long nativeFeedStream, FeedStream caller, int type); |
| void reportInfoCardViewed( |
| long nativeFeedStream, FeedStream caller, int type, int minimumViewIntervalSeconds); |
| void reportInfoCardClicked(long nativeFeedStream, FeedStream caller, int type); |
| void reportInfoCardDismissedExplicitly(long nativeFeedStream, FeedStream caller, int type); |
| void resetInfoCardStates(long nativeFeedStream, FeedStream caller, int type); |
| void invalidateContentCacheFor( |
| long nativeFeedStream, FeedStream caller, @StreamType int feedToInvalidate); |
| void reportContentSliceVisibleTimeForGoodVisits( |
| long nativeFeedStream, FeedStream caller, long elapsedMs); |
| } |
| } |