// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.bookmarks;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.style.ForegroundColorSpan;
import android.view.View;
import android.widget.CompoundButton;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;
import org.chromium.base.Callback;
import org.chromium.base.CallbackController;
import org.chromium.base.metrics.RecordUserAction;
import org.chromium.chrome.R;
import org.chromium.chrome.browser.bookmarks.BookmarkUiPrefs.BookmarkRowDisplayPref;
import org.chromium.chrome.browser.bookmarks.ImprovedBookmarkSaveFlowProperties.FolderText;
import org.chromium.chrome.browser.bookmarks.PowerBookmarkMetrics.PriceTrackingState;
import org.chromium.chrome.browser.commerce.PriceTrackingUtils;
import org.chromium.chrome.browser.feature_engagement.TrackerFactory;
import org.chromium.chrome.browser.price_tracking.PriceDropNotificationManagerFactory;
import org.chromium.chrome.browser.profiles.Profile;
import org.chromium.components.bookmarks.BookmarkId;
import org.chromium.components.bookmarks.BookmarkItem;
import org.chromium.components.browser_ui.styles.SemanticColorUtils;
import org.chromium.components.commerce.core.CommerceSubscription;
import org.chromium.components.commerce.core.ShoppingService;
import org.chromium.components.commerce.core.SubscriptionsObserver;
import org.chromium.components.feature_engagement.EventConstants;
import org.chromium.components.power_bookmarks.PowerBookmarkMeta;
import org.chromium.components.signin.identitymanager.ConsentLevel;
import org.chromium.components.signin.identitymanager.IdentityManager;
import org.chromium.ui.modelutil.PropertyModel;
/**
* Controls the bookmarks save-flow, which has 2 variants: standard, improved. The two variants have
* different properties, so each of the methods is branched to reflect that.
* BookmarkSaveFlowProperties shouldn't be used for the improved variant (it'll crash), and the same
* is true for ImprovedBookmarkSaveFlow properties with the standard variant. standard: The default
* save experience prior to android-improved-bookmarks. improved: The new experience for saving when
* android-improved-bookmarks is enabled.
*/
public class BookmarkSaveFlowMediator extends BookmarkModelObserver
implements SubscriptionsObserver {
private static final String FOLDER_TEXT_TOKEN = "%1$s";
private final Context mContext;
private final Runnable mCloseRunnable;
private final BookmarkImageFetcher mBookmarkImageFetcher;
private final CallbackController mCallbackController = new CallbackController();
private final PropertyModel mPropertyModel;
private final BookmarkModel mBookmarkModel;
private final ShoppingService mShoppingService;
private final Profile mProfile;
private final IdentityManager mIdentityManager;
private BookmarkId mBookmarkId;
private PowerBookmarkMeta mPowerBookmarkMeta;
private boolean mWasBookmarkMoved;
private boolean mIsNewBookmark;
private CommerceSubscription mSubscription;
private Callback<Boolean> mSubscriptionsManagerCallback;
private String mFolderName;
/**
* @param bookmarkModel The {@link BookmarkModel} which supplies the data.
* @param propertyModel The {@link PropertyModel} which allows the mediator to push data to the
* model.
* @param context The {@link Context} associated with this mediator.
* @param closeRunnable A {@link Runnable} which closes the bookmark save flow.
* @param shoppingService Used to manage the price-tracking subscriptions.
* @param bookmarkImageFetcher Used to fetch images/favicons for bookmarks.
* @param profile The current chrome profile.
* @param identityManager The {@link IdentityManager} which supplies the account data.
*/
public BookmarkSaveFlowMediator(
@NonNull BookmarkModel bookmarkModel,
@NonNull PropertyModel propertyModel,
@NonNull Context context,
@NonNull Runnable closeRunnable,
@NonNull ShoppingService shoppingService,
@NonNull BookmarkImageFetcher bookmarkImageFetcher,
@NonNull Profile profile,
@NonNull IdentityManager identityManager) {
mBookmarkModel = bookmarkModel;
mBookmarkModel.addObserver(this);
mPropertyModel = propertyModel;
mContext = context;
mCloseRunnable = closeRunnable;
mShoppingService = shoppingService;
if (mShoppingService != null) {
mShoppingService.addSubscriptionsObserver(this);
}
mBookmarkImageFetcher = bookmarkImageFetcher;
mProfile = profile;
mIdentityManager = identityManager;
}
/**
* Shows bottom sheet save-flow for the given {@link BookmarkId}.
*
* @param bookmarkId The {@link BookmarkId} to show.
* @param meta The power bookmark metadata for the given BookmarkId.
* @param fromExplicitTrackUi Whether the bookmark was added via a dedicated tracking entry
* point. This will change the UI of the bookmark save flow, either adding type-specific
* text (e.g. price tracking text) or adding UI bits to allow users to upgrade a regular
* bookmark.
* @param wasBookmarkMoved Whether the save flow is shown as a result of a moved bookmark.
* @param isNewBookmark Whether the bookmark is newly created.
*/
public void show(
BookmarkId bookmarkId,
@Nullable PowerBookmarkMeta meta,
boolean fromExplicitTrackUi,
boolean wasBookmarkMoved,
boolean isNewBookmark) {
RecordUserAction.record("MobileBookmark.SaveFlow.Show");
mBookmarkId = bookmarkId;
mPowerBookmarkMeta = meta;
mWasBookmarkMoved = wasBookmarkMoved;
mIsNewBookmark = isNewBookmark;
// Any flow from the explicit price tracking UI is attempting to track the bookmark. If the
// product is already being tracked and we got to this point, the call is a no-op.
if (fromExplicitTrackUi) {
PriceTrackingUtils.setPriceTrackingStateForBookmark(
mProfile,
bookmarkId.getId(),
true,
(success) -> {
// TODO(b:326488326): Show error message if not successful.
});
}
if (BookmarkFeatures.isAndroidImprovedBookmarksEnabled()) {
mPropertyModel.set(
ImprovedBookmarkSaveFlowProperties.BOOKMARK_ROW_CLICK_LISTENER,
this::onEditClicked);
} else {
mPropertyModel.set(
BookmarkSaveFlowProperties.EDIT_ONCLICK_LISTENER, this::onEditClicked);
mPropertyModel.set(
BookmarkSaveFlowProperties.FOLDER_SELECT_ONCLICK_LISTENER,
this::onFolderSelectClicked);
}
if (meta != null) {
mSubscription = PowerBookmarkUtils.createCommerceSubscriptionForPowerBookmarkMeta(meta);
}
BookmarkItem item = mBookmarkModel.getBookmarkById(bookmarkId);
bindBookmarkProperties(item, mPowerBookmarkMeta, mWasBookmarkMoved);
bindPowerBookmarkProperties(mPowerBookmarkMeta, fromExplicitTrackUi);
if (BookmarkFeatures.isAndroidImprovedBookmarksEnabled()) {
bindImage(item, meta);
}
}
private void bindBookmarkProperties(
BookmarkItem item, PowerBookmarkMeta meta, boolean wasBookmarkMoved) {
mFolderName = mBookmarkModel.getBookmarkTitle(item.getParentId());
if (BookmarkFeatures.isAndroidImprovedBookmarksEnabled()) {
mPropertyModel.set(ImprovedBookmarkSaveFlowProperties.TITLE, createTitleCharSequence());
mPropertyModel.set(
ImprovedBookmarkSaveFlowProperties.SUBTITLE,
createSubTitleCharSequnce(wasBookmarkMoved));
} else {
mPropertyModel.set(
BookmarkSaveFlowProperties.TITLE_TEXT,
mContext.getResources()
.getString(
wasBookmarkMoved
? R.string.bookmark_save_flow_title_move
: R.string.bookmark_save_flow_title));
mPropertyModel.set(
BookmarkSaveFlowProperties.FOLDER_SELECT_ICON,
BookmarkUtils.getFolderIcon(
mContext,
item.getId(),
mBookmarkModel,
BookmarkRowDisplayPref.COMPACT));
mPropertyModel.set(
BookmarkSaveFlowProperties.FOLDER_SELECT_ICON_ENABLED,
BookmarkUtils.isMovable(mBookmarkModel, item));
mPropertyModel.set(
BookmarkSaveFlowProperties.SUBTITLE_TEXT,
getFolderDisplayText(wasBookmarkMoved));
}
}
private CharSequence createTitleCharSequence() {
assert BookmarkFeatures.isAndroidImprovedBookmarksEnabled();
if (mBookmarkModel.areAccountBookmarkFoldersActive()) {
return createHighlightedCharSequence(
mContext,
new FolderText(
mContext.getString(
R.string.account_bookmark_save_flow_title, mFolderName),
mContext.getString(R.string.account_bookmark_save_flow_title)
.indexOf(FOLDER_TEXT_TOKEN),
mFolderName.length()));
} else {
return mContext.getString(R.string.bookmark_save_flow_title);
}
}
private CharSequence createSubTitleCharSequnce(boolean wasBookmarkMoved) {
if (mBookmarkModel.areAccountBookmarkFoldersActive()) {
BookmarkItem bookmarkItem = mBookmarkModel.getBookmarkById(mBookmarkId);
return bookmarkItem.isAccountBookmark()
? mIdentityManager.getPrimaryAccountInfo(ConsentLevel.SIGNIN).getEmail()
: mContext.getString(R.string.account_bookmark_save_flow_subtitle_local);
} else {
String folderDisplayTextRaw = getFolderDisplayTextRaw(wasBookmarkMoved);
String folderDisplayText = getFolderDisplayText(wasBookmarkMoved);
return createHighlightedCharSequence(
mContext,
new FolderText(
folderDisplayText,
folderDisplayTextRaw.indexOf(FOLDER_TEXT_TOKEN),
mFolderName.length()));
}
}
@VisibleForTesting
static CharSequence createHighlightedCharSequence(Context context, FolderText folderText) {
SpannableString ss = new SpannableString(folderText.getDisplayText());
ForegroundColorSpan fcs =
new ForegroundColorSpan(SemanticColorUtils.getDefaultTextColorAccent1(context));
ss.setSpan(
fcs,
folderText.getFolderTitleStartIndex(),
folderText.getFolderTitleEndIndex(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
return ss;
}
private void bindPowerBookmarkProperties(
@Nullable PowerBookmarkMeta meta, boolean fromExplicitTrackUi) {
if (meta == null) return;
if (meta.hasShoppingSpecifics()) {
setPriceTrackingNotificationUiEnabled(true);
setPriceTrackingIconForEnabledState(false);
if (BookmarkFeatures.isAndroidImprovedBookmarksEnabled()) {
mPropertyModel.set(
ImprovedBookmarkSaveFlowProperties.PRICE_TRACKING_SWITCH_CHECKED, false);
mPropertyModel.set(ImprovedBookmarkSaveFlowProperties.PRICE_TRACKING_VISIBLE, true);
mPropertyModel.set(
ImprovedBookmarkSaveFlowProperties.PRICE_TRACKING_SWITCH_LISTENER,
this::handlePriceTrackingSwitchToggle);
PriceTrackingUtils.isBookmarkPriceTracked(
mProfile,
mBookmarkId.getId(),
(subscribed) -> {
mPropertyModel.set(
ImprovedBookmarkSaveFlowProperties
.PRICE_TRACKING_SWITCH_CHECKED,
subscribed);
PowerBookmarkMetrics.reportBookmarkSaveFlowPriceTrackingState(
PriceTrackingState.PRICE_TRACKING_SHOWN);
});
} else {
mPropertyModel.set(BookmarkSaveFlowProperties.NOTIFICATION_SWITCH_VISIBLE, true);
mPropertyModel.set(
BookmarkSaveFlowProperties.NOTIFICATION_SWITCH_TITLE,
mContext.getResources()
.getString(R.string.enable_price_tracking_menu_item));
mPropertyModel.set(
BookmarkSaveFlowProperties.NOTIFICATION_SWITCH_TOGGLE_LISTENER,
this::handlePriceTrackingSwitchToggle);
mPropertyModel.set(BookmarkSaveFlowProperties.NOTIFICATION_SWITCH_TOGGLED, false);
PriceTrackingUtils.isBookmarkPriceTracked(
mProfile,
mBookmarkId.getId(),
(subscribed) -> {
mPropertyModel.set(
BookmarkSaveFlowProperties.NOTIFICATION_SWITCH_TOGGLED,
subscribed);
});
PowerBookmarkMetrics.reportBookmarkSaveFlowPriceTrackingState(
PriceTrackingState.PRICE_TRACKING_SHOWN);
}
}
}
void bindImage(BookmarkItem item, @Nullable PowerBookmarkMeta meta) {
Callback<Drawable> callback =
drawable -> {
mPropertyModel.set(
ImprovedBookmarkSaveFlowProperties.BOOKMARK_ROW_ICON, drawable);
};
mBookmarkImageFetcher.fetchImageForBookmarkWithFaviconFallback(item, callback);
}
void handlePriceTrackingSwitchToggle(CompoundButton view, boolean toggled) {
// Make sure we're getting feedback from the UI for the model, otherwise a failure won't be
// able to update the UI correctly.
setPriceTrackingToggleVisualsOnly(toggled);
if (mSubscriptionsManagerCallback == null) {
mSubscriptionsManagerCallback =
mCallbackController.makeCancelable(
(Boolean success) -> {
setPriceTrackingToggleVisualsOnly(success && view.isChecked());
setPriceTrackingNotificationUiEnabled(success);
});
}
setPriceTrackingIconForEnabledState(toggled);
PriceTrackingUtils.setPriceTrackingStateForBookmark(
mProfile,
mBookmarkId.getId(),
toggled,
mSubscriptionsManagerCallback,
mIsNewBookmark);
PowerBookmarkMetrics.reportBookmarkSaveFlowPriceTrackingState(
toggled
? PriceTrackingState.PRICE_TRACKING_ENABLED
: PriceTrackingState.PRICE_TRACKING_DISABLED);
}
void setPriceTrackingNotificationUiEnabled(boolean enabled) {
if (BookmarkFeatures.isAndroidImprovedBookmarksEnabled()) {
mPropertyModel.set(ImprovedBookmarkSaveFlowProperties.PRICE_TRACKING_ENABLED, enabled);
} else {
mPropertyModel.set(BookmarkSaveFlowProperties.NOTIFICATION_UI_ENABLED, enabled);
mPropertyModel.set(
BookmarkSaveFlowProperties.NOTIFICATION_SWITCH_SUBTITLE,
mContext.getResources()
.getString(
enabled
? R.string
.price_tracking_save_flow_notification_switch_subtitle
: R.string
.price_tracking_save_flow_notification_switch_subtitle_error));
}
}
void setPriceTrackingIconForEnabledState(boolean enabled) {
if (!BookmarkFeatures.isAndroidImprovedBookmarksEnabled()) {
mPropertyModel.set(
BookmarkSaveFlowProperties.NOTIFICATION_SWITCH_START_ICON_RES,
enabled
? R.drawable.price_tracking_enabled_filled
: R.drawable.price_tracking_disabled);
}
}
void destroy() {
mBookmarkModel.removeObserver(this);
if (mShoppingService != null) {
mShoppingService.removeSubscriptionsObserver(this);
}
mBookmarkId = null;
if (mCallbackController != null) {
mCallbackController.destroy();
}
}
@VisibleForTesting
void setPriceTrackingToggleVisualsOnly(boolean enabled) {
if (BookmarkFeatures.isAndroidImprovedBookmarksEnabled()) {
mPropertyModel.set(
ImprovedBookmarkSaveFlowProperties.PRICE_TRACKING_SWITCH_LISTENER, null);
mPropertyModel.set(
ImprovedBookmarkSaveFlowProperties.PRICE_TRACKING_SWITCH_CHECKED, enabled);
mPropertyModel.set(
ImprovedBookmarkSaveFlowProperties.PRICE_TRACKING_SWITCH_LISTENER,
this::handlePriceTrackingSwitchToggle);
} else {
mPropertyModel.set(
BookmarkSaveFlowProperties.NOTIFICATION_SWITCH_TOGGLE_LISTENER, null);
mPropertyModel.set(BookmarkSaveFlowProperties.NOTIFICATION_SWITCH_TOGGLED, enabled);
setPriceTrackingIconForEnabledState(enabled);
mPropertyModel.set(
BookmarkSaveFlowProperties.NOTIFICATION_SWITCH_TOGGLE_LISTENER,
this::handlePriceTrackingSwitchToggle);
}
}
void setSubscriptionForTesting(CommerceSubscription subscription) {
mSubscription = subscription;
}
// BookmarkModelObserver implementation
@Override
public void bookmarkModelChanged() {
// Possibility that the bookmark is deleted while in accessibility mode.
if (mBookmarkId == null || mBookmarkModel.getBookmarkById(mBookmarkId) == null) {
mCloseRunnable.run();
return;
}
BookmarkItem item = mBookmarkModel.getBookmarkById(mBookmarkId);
bindBookmarkProperties(item, mPowerBookmarkMeta, mWasBookmarkMoved);
}
// SubscriptionsObserver implementation
@Override
public void onSubscribe(CommerceSubscription subscription, boolean succeeded) {
if (!succeeded || !subscription.equals(mSubscription)) return;
setPriceTrackingToggleVisualsOnly(true);
// Make sure the notification channel is initialized when the user tracks the product.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
PriceDropNotificationManagerFactory.create(mProfile).createNotificationChannel();
}
}
@Override
public void onUnsubscribe(CommerceSubscription subscription, boolean succeeded) {
if (!succeeded || !subscription.equals(mSubscription)) return;
setPriceTrackingToggleVisualsOnly(false);
}
// Private functions
private String getFolderDisplayTextRaw(boolean wasBookmarkMoved) {
@StringRes int stringRes;
if (wasBookmarkMoved) {
stringRes = R.string.bookmark_page_moved_location;
} else {
stringRes = R.string.bookmark_page_saved_location;
}
return mContext.getString(stringRes);
}
private String getFolderDisplayText(boolean wasBookmarkMoved) {
@StringRes int stringRes;
if (wasBookmarkMoved) {
stringRes = R.string.bookmark_page_moved_location;
} else {
stringRes = R.string.bookmark_page_saved_location;
}
return mContext.getString(stringRes, mFolderName);
}
private void onEditClicked(View v) {
RecordUserAction.record("MobileBookmark.SaveFlow.EditBookmark");
BookmarkUtils.startEditActivity(mContext, mBookmarkId);
mCloseRunnable.run();
}
private void onFolderSelectClicked(View v) {
RecordUserAction.record("MobileBookmark.SaveFlow.EditFolder");
BookmarkUtils.startFolderPickerActivity(mContext, mBookmarkId);
TrackerFactory.getTrackerForProfile(mProfile)
.notifyEvent(EventConstants.SHOPPING_LIST_SAVE_FLOW_FOLDER_TAP);
mCloseRunnable.run();
}
}