Commit c93b69b4 authored by Clemens Arbesser's avatar Clemens Arbesser Committed by Commit Bot

[Autofill Assistant] Fixed animation issues in action carousel.

Bug: b/144075373
Change-Id: I559fb7bd9ad4273e3360604c24c230ce5edc0ef9
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2043799
Commit-Queue: Clemens Arbesser <arbesser@google.com>
Reviewed-by: default avatarSandro Maggi <sandromaggi@google.com>
Cr-Commit-Position: refs/heads/master@{#742170}
parent c048b6f5
......@@ -91,13 +91,13 @@ android_library("java") {
"java/src/org/chromium/chrome/browser/autofill_assistant/AutofillAssistantServiceInjector.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/AutofillAssistantUiController.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/BottomSheetUtils.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/EditDistance.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/FeedbackContext.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/SizeListenableLinearLayout.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/carousel/AssistantActionsCarouselCoordinator.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/carousel/AssistantActionsDecoration.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/carousel/AssistantCarouselModel.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/carousel/AssistantChip.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/carousel/AssistantChipAdapter.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/carousel/AssistantChipViewHolder.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/carousel/ButtonView.java",
"java/src/org/chromium/chrome/browser/autofill_assistant/details/AssistantDetails.java",
......@@ -242,7 +242,6 @@ android_library("test_java") {
"javatests/src/org/chromium/chrome/browser/autofill_assistant/AutofillAssistantTextUtilsTest.java",
"javatests/src/org/chromium/chrome/browser/autofill_assistant/AutofillAssistantUiTest.java",
"javatests/src/org/chromium/chrome/browser/autofill_assistant/AutofillAssistantUiTestUtil.java",
"javatests/src/org/chromium/chrome/browser/autofill_assistant/EditDistanceTest.java",
"javatests/src/org/chromium/chrome/browser/autofill_assistant/TestingAutofillAssistantModuleEntryProvider.java",
]
......
......@@ -19,7 +19,7 @@ import org.chromium.base.ObserverList;
import org.chromium.chrome.autofill_assistant.R;
import org.chromium.chrome.browser.ChromeActivity;
import org.chromium.chrome.browser.autofill_assistant.carousel.AssistantActionsCarouselCoordinator;
import org.chromium.chrome.browser.autofill_assistant.carousel.AssistantChip;
import org.chromium.chrome.browser.autofill_assistant.carousel.AssistantCarouselModel;
import org.chromium.chrome.browser.autofill_assistant.details.AssistantDetailsCoordinator;
import org.chromium.chrome.browser.autofill_assistant.form.AssistantFormCoordinator;
import org.chromium.chrome.browser.autofill_assistant.form.AssistantFormModel;
......@@ -35,7 +35,6 @@ import org.chromium.chrome.browser.widget.bottomsheet.BottomSheetContent;
import org.chromium.chrome.browser.widget.bottomsheet.BottomSheetController;
import org.chromium.chrome.browser.widget.bottomsheet.EmptyBottomSheetObserver;
import org.chromium.content_public.browser.WebContents;
import org.chromium.ui.modelutil.ListModel;
/**
* Coordinator responsible for the Autofill Assistant bottom bar.
......@@ -149,7 +148,7 @@ class AssistantBottomBarCoordinator
setChildMarginTop(mFormCoordinator.getView(), childSpacing);
// Hide the carousels when they are empty.
hideWhenEmpty(mActionsCoordinator.getView(), model.getActionsModel().getChipsModel());
hideWhenEmpty(mActionsCoordinator.getView(), model.getActionsModel());
// Set the horizontal margins of children. We don't set them on the payment request, the
// carousels or the form to allow them to take the full width of the sheet.
......@@ -325,18 +324,16 @@ class AssistantBottomBarCoordinator
/**
* Observe {@code model} such that the associated view is made invisible when it is empty.
*/
private void hideWhenEmpty(View carouselView, ListModel<AssistantChip> chipsModel) {
setCarouselVisibility(carouselView, chipsModel);
chipsModel.addObserver(new AbstractListObserver<Void>() {
@Override
public void onDataSetChanged() {
setCarouselVisibility(carouselView, chipsModel);
}
});
private void hideWhenEmpty(View carouselView, AssistantCarouselModel carouselModel) {
setCarouselVisibility(carouselView, carouselModel);
carouselModel.addObserver(
(source, propertyKey) -> setCarouselVisibility(carouselView, carouselModel));
}
private void setCarouselVisibility(View carouselView, ListModel<AssistantChip> chipsModel) {
carouselView.setVisibility(chipsModel.size() > 0 ? View.VISIBLE : View.GONE);
private void setCarouselVisibility(View carouselView, AssistantCarouselModel carouselModel) {
carouselView.setVisibility(carouselModel.get(AssistantCarouselModel.CHIPS).size() > 0
? View.VISIBLE
: View.GONE);
}
private void setHorizontalMargins(View view) {
......
......@@ -14,6 +14,7 @@ import org.chromium.base.task.PostTask;
import org.chromium.chrome.autofill_assistant.R;
import org.chromium.chrome.browser.ActivityTabProvider;
import org.chromium.chrome.browser.ChromeActivity;
import org.chromium.chrome.browser.autofill_assistant.carousel.AssistantCarouselModel;
import org.chromium.chrome.browser.autofill_assistant.carousel.AssistantChip;
import org.chromium.chrome.browser.autofill_assistant.carousel.AssistantChip.Type;
import org.chromium.chrome.browser.autofill_assistant.header.AssistantHeaderModel;
......@@ -296,15 +297,29 @@ class AutofillAssistantUiController {
@CalledByNative
private void setActions(List<AssistantChip> chips) {
setActionChips(chips);
// TODO(b/144075373): Move this to AssistantCarouselModel and AssistantHeaderModel. Move
// header chip logic to native.
AssistantCarouselModel model = getModel().getActionsModel();
model.setChips(chips);
setHeaderChip(chips);
}
@CalledByNative
private void setAllChipsVisibleExcept(String identifier, boolean visible) {
mCoordinator.getBottomBarCoordinator()
.getActionsCarouselCoordinator()
.setAllChipsVisibleExcept(identifier, visible);
AssistantCarouselModel model = getModel().getActionsModel();
List<AssistantChip> chips = model.get(AssistantCarouselModel.CHIPS);
// Copy the list and modify the copy. Modifying the actual list in-place will not fire the
// relevant change notifications. TODO(b/144075373): Refactor to avoid this deep copy,
// preferably by moving this to native.
List<AssistantChip> newChips = new ArrayList<>();
for (int i = 0; i < chips.size(); ++i) {
AssistantChip newChip = new AssistantChip(chips.get(i));
newChips.add(newChip);
if (!chips.get(i).getIdentifier().equals(identifier)) {
newChip.setVisible(visible);
}
}
model.setChips(newChips);
}
private void setHeaderChip(List<AssistantChip> chips) {
......@@ -320,12 +335,6 @@ class AutofillAssistantUiController {
getModel().getHeaderModel().set(AssistantHeaderModel.CHIP, headerChip);
}
private void setActionChips(List<AssistantChip> newChips) {
// TODO(b/144075373): Move this to AssistantActionsCarouselCoordinator.
mCoordinator.getBottomBarCoordinator().getActionsCarouselCoordinator().updateChips(
newChips);
}
@CalledByNative
private void setViewportMode(@AssistantViewportMode int mode) {
mCoordinator.getBottomBarCoordinator().setViewportMode(mode);
......
// Copyright 2019 The Chromium Authors. All rights reserved.
// 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.autofill_assistant;
import androidx.annotation.IntDef;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.ui.modelutil.ListModel;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* The <a href="https://en.wikipedia.org/wiki/Edit_distance">edit distance</a> is a mean of
* quantifying the distance/cost of two sequences (e.g. words or lists) by computing the minimum
* number of operations required to transform a source sequence into a target sequence.
*
* <p>One of the most popular example is the <a
* href="https://en.wikipedia.org/wiki/Levenshtein_distance">Levenshtein Distance</a> whose
* supported operations are item insertion, item deletion and item substitution.
*/
public final class EditDistance {
/**
* An {@link Equivalence} class to test for equivalence between 2 items of the same type.
*/
public interface Equivalence<T> {
/** Return whether {@code a} and {@code b} are equivalent. */
boolean apply(T a, T b);
}
/**
* Apply the set of operations with minimum cost necessary to transform {@code source} into
* {@code target} using the sets of edit operations defined by Levenshtein (insertion, deletion
* and substitution).
*
* <p>Insertions and deletions always have a cost of 1. Substituting a value by another costs 0
* if both values are equivalent according to {@code equivalence}, and it costs 1 otherwise.
*/
public static <T> void transform(
ListModel<T> source, List<T> target, Equivalence<T> equivalence) {
applyOperations(source, target, computeOperations(source, target, equivalence));
}
/**
* Compute the minimum cost set of operations to perform on {@code source} to transform it into
* {@code target}. Elements are compared using {@code equivalence} when testing for equivalence.
*
* <p>The positions used in the resulting list of operations are relative to the {@code source}
* sequence. For instance, let's say we have:
* <pre>
* source = [1, 2, 3]
* target = [2, 4, 3]
* </pre>
*
* then a minimum cost set of operations is:
* <pre>
* [
* Substitution(index = 2, value = 3)], // cost = 0
* Insertion(index = 2, value = 4), // cost = 1 total cost = 2
* Deletion(index = 0) // cost = 1
* ]
* </pre>
*
* <p>Note that there can be multiple solutions and there is currently no warranty on which
* one will be returned. For instance, another minimal set of operations for this example could
* be:
* <pre>
* [
* Substitution(index = 0, value = 2), // cost = 1
* Substitution(index = 1, value = 4)], // cost = 1 total cost = 2
* Substitution(index = 2, value = 3) // cost = 0
* ]
* </pre>
*/
private static <T> List<Operation> computeOperations(
ListModel<T> source, List<T> target, Equivalence<T> equivalence) {
int n = source.size();
int m = target.size();
// cache[i][j] stores the Levenshtein distance between source[0;i[ and target[0;j[, as
// well as references to other cache entries and an operation (insert, substitute, delete)
// to be able to reconstruct the optimal set of operations to perform on source such that it
// is equal to target.
CacheEntry[][] cache = new CacheEntry[n + 1][m + 1];
// Transforming empty list into empty list costs 0.
cache[0][0] = new CacheEntry(0, null, null);
// Cache[i][0].cost is the cost of transforming source[0;i[ into the empty list.
for (int i = 1; i <= n; i++) {
cache[i][0] = new CacheEntry(i, Operation.deletion(i - 1), cache[i - 1][0]);
}
// Cache[0][j].cost is the cost of transforming the empty list into target[0;j[.
for (int j = 1; j <= m; j++) {
cache[0][j] = new CacheEntry(j, Operation.insertion(0, j - 1), cache[0][j - 1]);
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
T aValue = source.get(i - 1);
T bValue = target.get(j - 1);
// 1) Substitution.
CacheEntry previousEntry = cache[i - 1][j - 1];
Operation operation = Operation.substitution(i - 1, j - 1);
int substitutionCost = equivalence.apply(aValue, bValue) ? 0 : 1;
int cost = previousEntry.mCost + substitutionCost;
// 2) Deletion.
CacheEntry deletionEntry = cache[i - 1][j];
if (deletionEntry.mCost + 1 < cost) {
cost = deletionEntry.mCost + 1;
operation = Operation.deletion(i - 1);
previousEntry = deletionEntry;
}
// 3) Insertion.
CacheEntry insertionEntry = cache[i][j - 1];
if (insertionEntry.mCost + 1 < cost) {
cost = insertionEntry.mCost + 1;
operation = Operation.insertion(i, j - 1);
previousEntry = insertionEntry;
}
cache[i][j] = new CacheEntry(cost, operation, previousEntry);
}
}
// Return the list of operations.
ArrayList<Operation> result = new ArrayList<>();
CacheEntry current = cache[n][m];
while (current != null) {
if (current.mOperation != null) {
result.add(current.mOperation);
}
current = current.mPreviousEntry;
}
sortOperations(result);
// TODO(crbug.com/806868): We might want to merge some operations (e.g. insertion of items
// at the same index or removal of a range) instead of inserting and removing items one by
// one.
return result;
}
/**
* Sort the operations such that we can apply them in the resulting order.
*
* <p>The logic is the following:
* 1) Apply all substitutions first, in any order.
* 2) Apply insertions and deletions altogether, such that:
* - deletions and insertions at index i will be performed before deletions and insertions at
* index j if i > j.
* - deletions at index i will be performed insertions at index i.
* - insertion at index i of target[j] will be performed before insertion at index i of
* target[k] if j > k.
*/
private static void sortOperations(List<Operation> operations) {
Collections.sort(operations, (a, b) -> {
// We perform substitutions first.
int c = ApiCompatibilityUtils.compareBoolean(
b.mType == Operation.Type.SUBSTITUTION, a.mType == Operation.Type.SUBSTITUTION);
if (c != 0) {
return c;
}
// We apply deletions and insertions in decreasing index order.
c = Integer.compare(b.mSourceIndex, a.mSourceIndex);
if (c != 0) {
return c;
}
// When the indices are equal, we first perform the deletion at that index.
c = ApiCompatibilityUtils.compareBoolean(
b.mType == Operation.Type.DELETION, a.mType == Operation.Type.DELETION);
if (c != 0) {
return c;
}
// If we need to insert multiple values at the same index, we do it in decreasing
// order of target index.
return Integer.compare(b.mTargetIndex, b.mTargetIndex);
});
}
/**
* Apply operations on {@code source}.
*/
private static <T> void applyOperations(
ListModel<T> source, List<T> target, List<Operation> operations) {
for (int i = 0; i < operations.size(); i++) {
Operation operation = operations.get(i);
switch (operation.mType) {
case Operation.Type.INSERTION:
source.add(operation.mSourceIndex, target.get(operation.mTargetIndex));
break;
case Operation.Type.SUBSTITUTION:
source.update(operation.mSourceIndex, target.get(operation.mTargetIndex));
break;
case Operation.Type.DELETION:
source.removeAt(operation.mSourceIndex);
break;
}
}
}
/** An operation that can be applied to a sequence. */
private static class Operation {
@IntDef({Type.INSERTION, Type.SUBSTITUTION, Type.DELETION})
@Retention(RetentionPolicy.SOURCE)
@interface Type {
int INSERTION = 0;
int SUBSTITUTION = 1;
int DELETION = 2;
}
/** The type of this operation. */
final @Type int mType;
/**
* An index from the source sequence that represents:
* - at which index a value should be inserted if mType == INSERTION.
* - at which index we should change the value if mType == SUBSTITUTION.
* - at which index a value should be deleted if mType == DELETION.
*/
final int mSourceIndex;
/**
* An index from the target sequence that represents:
* - the index of the value to insert in the source sequence if mType == INSERTION.
* - the index of the new value if mType == SUBSTITUTION.
* - (invalid index = -1) if mType == DELETION.
*/
final int mTargetIndex;
Operation(@Type int type, int sourceIndex, int targetIndex) {
this.mType = type;
this.mSourceIndex = sourceIndex;
this.mTargetIndex = targetIndex;
}
static Operation insertion(int sourceIndex, int targetIndex) {
return new Operation(Type.INSERTION, sourceIndex, targetIndex);
}
static Operation substitution(int sourceIndex, int targetIndex) {
return new Operation(Type.SUBSTITUTION, sourceIndex, targetIndex);
}
static Operation deletion(int sourceIndex) {
return new Operation(Type.DELETION, sourceIndex, -1);
}
}
/** Util class used to store the result of a DP sub problem in #computeOperations().*/
private static class CacheEntry {
final int mCost;
final Operation mOperation;
final CacheEntry mPreviousEntry;
CacheEntry(int cost, Operation operation, CacheEntry previousEntry) {
this.mCost = cost;
this.mOperation = operation;
this.mPreviousEntry = previousEntry;
}
}
}
......@@ -13,12 +13,6 @@ import android.view.View;
import android.view.ViewGroup;
import org.chromium.chrome.autofill_assistant.R;
import org.chromium.chrome.browser.autofill_assistant.EditDistance;
import org.chromium.ui.modelutil.ListModel;
import org.chromium.ui.modelutil.RecyclerViewAdapter;
import org.chromium.ui.modelutil.SimpleRecyclerViewMcp;
import java.util.List;
/**
* A coordinator responsible for suggesting chips to the user. If there is one chip to display, it
......@@ -42,14 +36,13 @@ import java.util.List;
* | |
*/
public class AssistantActionsCarouselCoordinator {
private final AssistantCarouselModel mModel;
private final RecyclerView mView;
public AssistantActionsCarouselCoordinator(Context context, AssistantCarouselModel model) {
mModel = model;
mView = new RecyclerView(context);
mView.setTag(RECYCLER_VIEW_TAG);
AssistantChipAdapter chipAdapter = new AssistantChipAdapter();
mView.setAdapter(chipAdapter);
CustomLayoutManager layoutManager = new CustomLayoutManager();
// Workaround for b/128679161.
......@@ -70,77 +63,17 @@ public class AssistantActionsCarouselCoordinator {
* context.getResources().getDimensionPixelSize(
R.dimen.autofill_assistant_button_bg_vertical_inset)));
mView.setAdapter(new RecyclerViewAdapter<>(
new SimpleRecyclerViewMcp<>(model.getChipsModel(),
AssistantChipViewHolder::getViewType, AssistantChipViewHolder::bind),
AssistantChipViewHolder::create));
model.addObserver((source, propertyKey) -> {
if (propertyKey == AssistantCarouselModel.CHIPS) {
chipAdapter.setChips(model.get(AssistantCarouselModel.CHIPS));
}
});
}
public RecyclerView getView() {
return mView;
}
public void updateChips(List<AssistantChip> newChips) {
ListModel<AssistantChip> oldChips = mModel.getChipsModel();
if (!areChipsEqual(oldChips, newChips)) {
// We apply the minimum set of operations on the current chips to transform it in the
// target list of chips. When testing for chip equivalence, we only compare their type
// and text but all substitutions will still be applied so we are sure we display the
// given {@code chips} with their associated callbacks.
EditDistance.transform(oldChips, newChips, AssistantChip::equals);
} else {
for (int i = 0; i < oldChips.size(); ++i) {
AssistantChip oldChip = oldChips.get(i);
AssistantChip newChip = newChips.get(i);
// We assume that the enabled state is the only thing that may change.
if (oldChip.isDisabled() == newChip.isDisabled()) {
continue;
}
View view = mView.getLayoutManager().findViewByPosition(i);
if (view == null) {
oldChips.update(i, newChip);
} else {
oldChip.setDisabled(newChip.isDisabled());
view.setEnabled(!newChip.isDisabled());
}
}
}
}
/** Changes the visibility of all chips not matching the identifier **/
public void setAllChipsVisibleExcept(String identifier, boolean visible) {
ListModel<AssistantChip> chips = mModel.getChipsModel();
for (int i = 0; i < chips.size(); ++i) {
View view = mView.getLayoutManager().findViewByPosition(i);
if (view != null && !chips.get(i).getIdentifier().equals(identifier)) {
view.setVisibility(visible ? View.VISIBLE : View.GONE);
}
}
}
/**
* Returns |true| if all chips in the list are considered equal, meaning the same except their
* |isDisabled()| state.
*/
private boolean areChipsEqual(ListModel<AssistantChip> oldChips, List<AssistantChip> newChips) {
if (oldChips.size() != newChips.size()) {
return false;
}
for (int i = 0; i < oldChips.size(); ++i) {
AssistantChip oldChip = oldChips.get(i);
AssistantChip newChip = newChips.get(i);
if (!oldChip.equals(newChip)) {
return false;
}
}
return true;
}
// TODO(crbug.com/806868): Handle RTL layouts.
// TODO(crbug.com/806868): Recycle invisible children instead of laying all of them out.
static class CustomLayoutManager extends RecyclerView.LayoutManager {
......
......@@ -101,12 +101,28 @@ class AssistantActionsDecoration extends RecyclerView.ItemDecoration {
}
View lastChild = parent.getChildAt(parent.getChildCount() - 1);
View beforeLastChild = parent.getChildAt(parent.getChildCount() - 2);
mLastChildRect.left = lastChild.getLeft() + lastChild.getTranslationX();
mLastChildRect.top = lastChild.getTop() + lastChild.getTranslationY() + mVerticalInset;
mLastChildRect.right = lastChild.getRight() + lastChild.getTranslationX();
mLastChildRect.bottom =
lastChild.getBottom() + lastChild.getTranslationY() - mVerticalInset;
// Early return to avoid drawing last button decoration if no chip overlaps with the cancel
// chip. Note that there are spurious updates where the last chip's position is
// intermittently in the wrong position. The workaround is to check if the combined width of
// all chips fits without scrolling or not.
// TODO(b/144075373): Fix this properly by avoiding the spurious updates.
OrientationHelper orientationHelper = mLayoutManager.mOrientationHelper;
int sumDecoratedWidth = 0;
for (int i = 0; i < parent.getChildCount(); ++i) {
sumDecoratedWidth += orientationHelper.getDecoratedEnd(parent.getChildAt(i))
- orientationHelper.getDecoratedStart(parent.getChildAt(i));
}
if (sumDecoratedWidth < mLayoutManager.getWidth()) {
return;
}
canvas.save();
// Don't draw on the last child.
......@@ -116,7 +132,6 @@ class AssistantActionsDecoration extends RecyclerView.ItemDecoration {
canvas.clipPath(mLastChildPath, Region.Op.DIFFERENCE);
// Overlay children close to the last child with a semi-transparent paint.
OrientationHelper orientationHelper = mLayoutManager.mOrientationHelper;
float lastChildRight = orientationHelper.getDecoratedEnd(lastChild);
for (int i = parent.getChildCount() - 2; i >= 0; i--) {
View child = parent.getChildAt(i);
......@@ -136,8 +151,6 @@ class AssistantActionsDecoration extends RecyclerView.ItemDecoration {
canvas.drawRect(mChildRect, mOverlayPaint);
}
View beforeLastChild = parent.getChildAt(parent.getChildCount() - 2);
// Draw a fixed size white-to-transparent linear gradient from left to right.
mGradientDrawable.setBounds(
0, mVerticalSpacing, mGradientWidth, parent.getHeight() - mVerticalSpacing);
......
......@@ -4,15 +4,24 @@
package org.chromium.chrome.browser.autofill_assistant.carousel;
import org.chromium.ui.modelutil.ListModel;
import org.chromium.ui.modelutil.PropertyModel;
import java.util.ArrayList;
import java.util.List;
/**
* State for the carousel of the Autofill Assistant.
*/
public class AssistantCarouselModel {
private final ListModel<AssistantChip> mChipsModel = new ListModel<>();
public class AssistantCarouselModel extends PropertyModel {
public static final WritableObjectPropertyKey<List<AssistantChip>> CHIPS =
new WritableObjectPropertyKey<>();
public AssistantCarouselModel() {
super(CHIPS);
set(CHIPS, new ArrayList<>());
}
public ListModel<AssistantChip> getChipsModel() {
return mChipsModel;
public void setChips(List<AssistantChip> chips) {
set(CHIPS, chips);
}
}
......@@ -58,6 +58,9 @@ public class AssistantChip {
/** Whether this chip is enabled or not. */
private boolean mDisabled;
/** Whether this chip is visible or not. */
private boolean mVisible;
/**
* Whether this chip is sticky. A sticky chip will be a candidate to be displayed in the header
* if the peek mode of the sheet is HANDLE_HEADER.
......@@ -75,11 +78,23 @@ public class AssistantChip {
mIcon = icon;
mText = text;
mDisabled = disabled;
mVisible = true;
mSticky = sticky;
mIdentifier = identifier;
mSelectedListener = selectedListener;
}
public AssistantChip(AssistantChip other) {
mType = other.mType;
mIcon = other.mIcon;
mText = other.mText;
mDisabled = other.mDisabled;
mVisible = other.mVisible;
mSticky = other.mSticky;
mIdentifier = other.mIdentifier;
mSelectedListener = other.mSelectedListener;
}
public int getType() {
return mType;
}
......@@ -96,15 +111,18 @@ public class AssistantChip {
return mDisabled;
}
/**
* Set the disabled state of the {@link AssistantChip} object. Changing this flag will not
* affect the view this chip is bound to. Use this to keep the model and view in sync, if
* the view's state changes.
*/
public boolean isVisible() {
return mVisible;
}
public void setDisabled(boolean disabled) {
mDisabled = disabled;
}
public void setVisible(boolean visible) {
mVisible = visible;
}
public boolean isSticky() {
return mSticky;
}
......@@ -119,15 +137,14 @@ public class AssistantChip {
@Override
public boolean equals(Object other) {
if (other == this) {
return true;
}
if (!(other instanceof AssistantChip)) {
return false;
}
AssistantChip that = (AssistantChip) other;
return this.getType() == that.getType() && this.getText().equals(that.getText())
&& this.getIcon() == that.getIcon() && this.isSticky() == that.isSticky();
&& this.getIcon() == that.getIcon() && this.isSticky() == that.isSticky()
&& this.getIdentifier().equals(that.getIdentifier())
&& this.isDisabled() == that.isDisabled() && this.isVisible() == that.isVisible();
}
}
// Copyright 2020 The Chromium Authors. All rights reserved.
// 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.autofill_assistant.carousel;
import android.support.annotation.NonNull;
import android.support.annotation.Nullable;
import android.support.v7.util.DiffUtil;
import android.support.v7.util.ListUpdateCallback;
import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.view.ViewGroup;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Custom RecyclerView adapter for instances of {@code AssistantChip}.
*/
public class AssistantChipAdapter extends RecyclerView.Adapter<AssistantChipViewHolder> {
private final List<AssistantChip> mChips = new ArrayList<>();
void setChips(List<AssistantChip> chips) {
DiffUtil.DiffResult diffResult = DiffUtil.calculateDiff(new DiffUtil.Callback() {
@Override
public int getOldListSize() {
return mChips.size();
}
@Override
public int getNewListSize() {
return chips.size();
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) {
AssistantChip oldChip = mChips.get(oldItemPosition);
AssistantChip newChip = chips.get(newItemPosition);
return newChip.getType() == oldChip.getType()
&& newChip.getText().equals(oldChip.getText())
&& newChip.getIcon() == oldChip.getIcon()
&& newChip.isSticky() == oldChip.isSticky();
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) {
return chips.get(newItemPosition).equals(mChips.get(oldItemPosition));
}
@Nullable
@Override
public Object getChangePayload(int oldItemPosition, int newItemPosition) {
return chips.get(newItemPosition);
}
});
// TODO(b/144075373): The following should work, but does not fire change notifications
// properly, leading to missing change animations:
// mChips.clear();
// mChips.addAll(chips);
// diffResult.dispatchUpdatesTo(this);
// The workaround is to update manually:
diffResult.dispatchUpdatesTo(new ListUpdateCallback() {
@Override
public void onInserted(int position, int count) {
for (int i = 0; i < count; ++i) {
mChips.add(position + i, chips.get(i));
notifyItemInserted(position);
}
}
@Override
public void onRemoved(int position, int count) {
for (int i = 0; i < count; ++i) {
mChips.remove(position);
notifyItemRemoved(position);
}
}
@Override
public void onMoved(int fromPosition, int toPosition) {
Collections.swap(mChips, fromPosition, toPosition);
notifyItemMoved(fromPosition, toPosition);
}
@Override
public void onChanged(int position, int count, @Nullable Object payload) {
assert payload instanceof AssistantChip;
AssistantChip newChip = (AssistantChip) payload;
mChips.set(position, newChip);
notifyItemChanged(position);
}
});
}
@NonNull
@Override
public AssistantChipViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
return AssistantChipViewHolder.create(parent, viewType);
}
@Override
public void onBindViewHolder(@NonNull AssistantChipViewHolder viewHolder, int position) {
viewHolder.bind(mChips.get(position));
}
@Override
public void onBindViewHolder(@NonNull AssistantChipViewHolder viewHolder, int position,
@Nullable List<Object> payloads) {
// Perform in-place update instead of full bind when possible.
if (payloads != null && payloads.size() > 0) {
AssistantChip changedChip = (AssistantChip) payloads.get(payloads.size() - 1);
viewHolder.getView().setEnabled(!changedChip.isDisabled());
viewHolder.getView().setVisibility(changedChip.isVisible() ? View.VISIBLE : View.GONE);
return;
}
onBindViewHolder(viewHolder, position);
}
@Override
public int getItemCount() {
return mChips.size();
}
@Override
public int getItemViewType(int position) {
return mChips.get(position).getType();
}
}
......@@ -30,7 +30,7 @@ public class AssistantChipViewHolder extends ViewHolder {
public static AssistantChipViewHolder create(ViewGroup parent, int viewType) {
LayoutInflater layoutInflater = LayoutInflater.from(parent.getContext());
ButtonView view = null;
switch (viewType % AssistantChip.Type.NUM_ENTRIES) {
switch (viewType) {
case AssistantChip.Type.CHIP_ASSISTIVE:
view = (ButtonView) layoutInflater.inflate(
R.layout.autofill_assistant_button_assistive, /* root= */ null);
......@@ -54,12 +54,6 @@ public class AssistantChipViewHolder extends ViewHolder {
}
public static int getViewType(AssistantChip chip) {
// We add AssistantChip.Type.CHIP_TYPE_NUMBER to differentiate between enabled and disabled
// chips of the same type. Ideally, we should return a (type, disabled) tuple but
// RecyclerView does not allow that.
if (chip.isDisabled()) {
return chip.getType() + AssistantChip.Type.NUM_ENTRIES;
}
return chip.getType();
}
......@@ -73,6 +67,7 @@ public class AssistantChipViewHolder extends ViewHolder {
public void bind(AssistantChip chip) {
mView.setEnabled(!chip.isDisabled());
mView.setVisibility(chip.isVisible() ? View.VISIBLE : View.GONE);
String text = chip.getText();
if (text.isEmpty()) {
......
......@@ -34,6 +34,10 @@ import org.chromium.chrome.browser.customtabs.CustomTabActivityTestRule;
import org.chromium.chrome.test.ChromeJUnit4ClassRunner;
import org.chromium.content_public.browser.test.util.TestThreadUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* Tests for the autofill assistant actions carousel.
*/
......@@ -75,7 +79,7 @@ public class AutofillAssistantActionsCarouselUiTest {
AssistantCarouselModel model = new AssistantCarouselModel();
AssistantActionsCarouselCoordinator coordinator = createCoordinator(model);
assertThat(model.getChipsModel().size(), is(0));
assertThat(model.get(AssistantCarouselModel.CHIPS).size(), is(0));
assertThat(coordinator.getView().getAdapter().getItemCount(), is(0));
}
......@@ -88,9 +92,10 @@ public class AutofillAssistantActionsCarouselUiTest {
TestThreadUtils.runOnUiThreadBlocking(
()
-> model.getChipsModel().add(
new AssistantChip(AssistantChip.Type.BUTTON_HAIRLINE,
AssistantChip.Icon.NONE, "Test", false, true, "", null)));
-> model.set(AssistantCarouselModel.CHIPS,
Collections.singletonList(new AssistantChip(
AssistantChip.Type.BUTTON_HAIRLINE, AssistantChip.Icon.NONE,
"Test", false, true, "", null))));
// Chip was created and is displayed on the screen.
onView(is(coordinator.getView()))
......@@ -108,14 +113,15 @@ public class AutofillAssistantActionsCarouselUiTest {
// Note: this should be a small number that fits on screen without scrolling.
int numChips = 3;
TestThreadUtils.runOnUiThreadBlocking(() -> {
for (int i = 0; i < numChips; i++) {
model.getChipsModel().add(new AssistantChip(AssistantChip.Type.BUTTON_HAIRLINE,
AssistantChip.Icon.NONE, "T" + i, false, false, "", null));
}
model.getChipsModel().add(new AssistantChip(AssistantChip.Type.BUTTON_HAIRLINE,
AssistantChip.Icon.NONE, "X", false, true, "", null));
});
List<AssistantChip> chips = new ArrayList<>();
for (int i = 0; i < numChips; i++) {
chips.add(new AssistantChip(AssistantChip.Type.BUTTON_HAIRLINE, AssistantChip.Icon.NONE,
"T" + i, false, false, "", null));
}
chips.add(new AssistantChip(AssistantChip.Type.BUTTON_HAIRLINE, AssistantChip.Icon.NONE,
"X", false, true, "", null));
TestThreadUtils.runOnUiThreadBlocking(() -> model.set(AssistantCarouselModel.CHIPS, chips));
// Cancel chip is displayed to the user.
onView(withText("X")).check(matches(isDisplayed()));
......@@ -135,14 +141,14 @@ public class AutofillAssistantActionsCarouselUiTest {
// Note: this should be a large number that does not fit on screen without scrolling.
int numChips = 30;
TestThreadUtils.runOnUiThreadBlocking(() -> {
for (int i = 0; i < numChips; i++) {
model.getChipsModel().add(new AssistantChip(AssistantChip.Type.BUTTON_HAIRLINE,
AssistantChip.Icon.NONE, "Test" + i, false, false, "", null));
}
model.getChipsModel().add(new AssistantChip(AssistantChip.Type.BUTTON_HAIRLINE,
AssistantChip.Icon.NONE, "Cancel", false, true, "", null));
});
List<AssistantChip> chips = new ArrayList<>();
for (int i = 0; i < numChips; i++) {
chips.add(new AssistantChip(AssistantChip.Type.BUTTON_HAIRLINE, AssistantChip.Icon.NONE,
"Test" + i, false, false, "", null));
}
chips.add(new AssistantChip(AssistantChip.Type.BUTTON_HAIRLINE, AssistantChip.Icon.NONE,
"Cancel", false, true, "", null));
TestThreadUtils.runOnUiThreadBlocking(() -> model.set(AssistantCarouselModel.CHIPS, chips));
// Cancel chip is initially displayed to the user.
onView(withText("Cancel")).check(matches(isDisplayed()));
......
......@@ -221,7 +221,7 @@ public class AutofillAssistantUiTest {
new AssistantChip(AssistantChip.Type.CHIP_ASSISTIVE, AssistantChip.Icon.NONE,
"chip 1",
/* disabled= */ false, /* sticky= */ false, "", mRunnableMock));
ThreadUtils.runOnUiThreadBlocking(() -> carouselModel.getChipsModel().set(chips));
ThreadUtils.runOnUiThreadBlocking(() -> carouselModel.setChips(chips));
RecyclerView chipsViewContainer = carouselCoordinator.getView();
Assert.assertEquals(2, chipsViewContainer.getAdapter().getItemCount());
......
// Copyright 2019 The Chromium Authors. All rights reserved.
// 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.autofill_assistant;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.verify;
import android.support.test.filters.SmallTest;
import org.junit.Assert;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;
import org.mockito.InOrder;
import org.mockito.Mockito;
import org.mockito.junit.MockitoJUnit;
import org.mockito.junit.MockitoRule;
import org.chromium.base.test.util.DisabledTest;
import org.chromium.ui.modelutil.ListModel;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* Unit test suite for {@link EditDistance}.
*/
@RunWith(JUnit4.class)
public class EditDistanceTest {
@Rule
public MockitoRule mMockitoRule = MockitoJUnit.rule();
@Test
@SmallTest
public void testEmptySource() {
ListModel<Integer> spiedListModel = createSpiedListModel();
testTransformation(spiedListModel, Arrays.asList(1, 2, 3));
InOrder inOrder = inOrder(spiedListModel);
inOrder.verify(spiedListModel).add(0, 3);
inOrder.verify(spiedListModel).add(0, 2);
inOrder.verify(spiedListModel).add(0, 1);
}
@Test
@SmallTest
@DisabledTest(message = "crbug.com/963672")
public void testEmptyTarget() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 2, 3);
testTransformation(spiedListModel, Collections.emptyList());
InOrder inOrder = inOrder(spiedListModel);
inOrder.verify(spiedListModel).removeAt(2);
inOrder.verify(spiedListModel).removeAt(1);
inOrder.verify(spiedListModel).removeAt(0);
}
@Test
@SmallTest
public void testEmptySourceAndEmptyTarget() {
testTransformation(createSpiedListModel(), Collections.emptyList());
}
@Test
@SmallTest
public void testEqualSourceAndTarget() {
testTransformation(createSpiedListModel(1, 2, 3), Arrays.asList(1, 2, 3));
}
@Test
@SmallTest
public void testInsertBeginning() {
ListModel<Integer> spiedListModel = createSpiedListModel(2, 3);
testTransformation(spiedListModel, Arrays.asList(1, 2, 3));
verify(spiedListModel).add(0, 1);
}
@Test
@SmallTest
public void testInsertMiddle() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 3);
testTransformation(spiedListModel, Arrays.asList(1, 2, 3));
verify(spiedListModel).add(1, 2);
}
@Test
@SmallTest
public void testInsertEnd() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 2);
testTransformation(spiedListModel, Arrays.asList(1, 2, 3));
verify(spiedListModel).add(2, 3);
}
@Test
@SmallTest
public void testDeleteBeginning() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 2, 3);
testTransformation(spiedListModel, Arrays.asList(2, 3));
verify(spiedListModel).removeAt(0);
}
@Test
@SmallTest
public void testDeleteMiddle() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 2, 3);
testTransformation(spiedListModel, Arrays.asList(1, 3));
verify(spiedListModel).removeAt(1);
}
@Test
@SmallTest
@DisabledTest(message = "crbug.com/963672")
public void testDeleteEnd() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 2, 3);
testTransformation(spiedListModel, Arrays.asList(1, 2));
verify(spiedListModel).removeAt(2);
}
@Test
@SmallTest
public void testSubstitutionBeginning() {
ListModel<Integer> spiedListModel = createSpiedListModel(0, 2, 3);
testTransformation(spiedListModel, Arrays.asList(1, 2, 3));
verify(spiedListModel).update(0, 1);
}
@Test
@SmallTest
public void testSubstitutionMiddle() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 0, 3);
testTransformation(spiedListModel, Arrays.asList(1, 2, 3));
verify(spiedListModel).update(1, 2);
}
@Test
@SmallTest
public void testSubstitutionEnd() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 2, 0);
testTransformation(spiedListModel, Arrays.asList(1, 2, 3));
verify(spiedListModel).update(2, 3);
}
@Test
@SmallTest
public void testMultipleInsert() {
ListModel<Integer> spiedListModel = createSpiedListModel(3, 6);
testTransformation(spiedListModel, Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8));
InOrder inOrder = inOrder(spiedListModel);
inOrder.verify(spiedListModel).add(2, 8);
inOrder.verify(spiedListModel).add(2, 7);
inOrder.verify(spiedListModel).add(1, 5);
inOrder.verify(spiedListModel).add(1, 4);
inOrder.verify(spiedListModel).add(0, 2);
inOrder.verify(spiedListModel).add(0, 1);
}
@Test
@SmallTest
@DisabledTest(message = "crbug.com/963672")
public void testMultipleDelete() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 2, 3, 4, 5, 6, 7, 8);
testTransformation(spiedListModel, Arrays.asList(3, 6));
InOrder inOrder = inOrder(spiedListModel);
inOrder.verify(spiedListModel).removeAt(7);
inOrder.verify(spiedListModel).removeAt(6);
inOrder.verify(spiedListModel).removeAt(4);
inOrder.verify(spiedListModel).removeAt(3);
inOrder.verify(spiedListModel).removeAt(1);
inOrder.verify(spiedListModel).removeAt(0);
}
@Test
@SmallTest
public void testMultipleSubstitutions() {
ListModel<Integer> spiedListModel = createSpiedListModel(0, 0, 3, 0, 0, 6, 0, 0);
testTransformation(spiedListModel, Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8));
verify(spiedListModel).update(0, 1);
verify(spiedListModel).update(1, 2);
verify(spiedListModel).update(3, 4);
verify(spiedListModel).update(4, 5);
verify(spiedListModel).update(6, 7);
verify(spiedListModel).update(7, 8);
}
@Test
@SmallTest
public void testPopAndAppend() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 2, 3);
testTransformation(spiedListModel, Arrays.asList(2, 3, 4));
InOrder inOrder = inOrder(spiedListModel);
inOrder.verify(spiedListModel).add(3, 4);
inOrder.verify(spiedListModel).removeAt(0);
}
@Test
@SmallTest
public void testAppendAndPop() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 2, 3);
testTransformation(spiedListModel, Arrays.asList(0, 1, 2));
InOrder inOrder = inOrder(spiedListModel);
inOrder.verify(spiedListModel).removeAt(2);
inOrder.verify(spiedListModel).add(0, 0);
}
@Test
@SmallTest
public void testMixedOperations() {
ListModel<Integer> spiedListModel = createSpiedListModel(1, 2, 3, 4, 5, 6);
testTransformation(spiedListModel, Arrays.asList(2, 8, 4, 5, 7, 6));
// 0-cost substitutions.
verify(spiedListModel).update(1, 2);
verify(spiedListModel).update(3, 4);
verify(spiedListModel).update(4, 5);
verify(spiedListModel).update(5, 6);
verify(spiedListModel).update(2, 8);
InOrder inOrder = inOrder(spiedListModel);
inOrder.verify(spiedListModel).add(5, 7);
inOrder.verify(spiedListModel).removeAt(0);
}
private void testTransformation(ListModel<Integer> source, List<Integer> target) {
EditDistance.transform(source, target, Integer::equals);
List<Integer> sourceList = new ArrayList<>();
for (Integer value : source) {
sourceList.add(value);
}
Assert.assertEquals(target, sourceList);
}
private static ListModel<Integer> createSpiedListModel(Integer... values) {
ListModel<Integer> model = new ListModel<>();
model.set(values);
return Mockito.spy(model);
}
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment