Commit 182d3add authored by Clemens Arbesser's avatar Clemens Arbesser Committed by Commit Bot

Reland "[Autofill Assistant] Added ForEach interaction."

This is a reland of ae61a59a

I reproduced the build failure and added the missing include.

Original change's description:
> [Autofill Assistant] Added ForEach interaction.
>
> Note: this is an alternative solution for http://crrev/c/2235698
>
> This interaction executes a number of callbacks for the input loop value. This is intended to be used to inflate UI elements for client-only values, i.e., for values that the backend can't specify.
>
> Internally, ForEach loops are implemented by introducing the concept of callback contexts, which will change value and view lookup accordingly.
>
> In particular, callback contexts are used to automatically replace placeholders of the form ${i} in value and view identifiers (where 'i' is the loop identifier). This allows creating and referencing values and views with templated names, such as "created_view_${i}" and "value[${i}]".
>
> Bug: b/145043394
> Change-Id: I53089252fe1cc14b2b1fb74cfc56d7314bc4b37c
> Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2241975
> Commit-Queue: Clemens Arbesser <arbesser@google.com>
> Reviewed-by: Sandro Maggi <sandromaggi@google.com>
> Reviewed-by: Marian Fechete <marianfe@google.com>
> Cr-Commit-Position: refs/heads/master@{#780785}

Bug: b/145043394
Change-Id: I36b64f8d5a64f66f9e9081137a61a20c31ade8cd
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2259847Reviewed-by: default avatarMathias Carlen <mcarlen@chromium.org>
Commit-Queue: Clemens Arbesser <arbesser@google.com>
Cr-Commit-Position: refs/heads/master@{#781253}
parent 7298ff01
......@@ -27,6 +27,7 @@ import static androidx.test.espresso.matcher.ViewMatchers.withTagValue;
import static androidx.test.espresso.matcher.ViewMatchers.withText;
import static org.hamcrest.CoreMatchers.allOf;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.is;
import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.Matchers.containsInAnyOrder;
......@@ -65,6 +66,7 @@ import org.chromium.base.test.util.DisabledTest;
import org.chromium.chrome.autofill_assistant.R;
import org.chromium.chrome.browser.autofill_assistant.generic_ui.AssistantDimension;
import org.chromium.chrome.browser.autofill_assistant.proto.ActionProto;
import org.chromium.chrome.browser.autofill_assistant.proto.AutofillFormatProto;
import org.chromium.chrome.browser.autofill_assistant.proto.BooleanAndProto;
import org.chromium.chrome.browser.autofill_assistant.proto.BooleanList;
import org.chromium.chrome.browser.autofill_assistant.proto.BooleanNotProto;
......@@ -77,7 +79,9 @@ import org.chromium.chrome.browser.autofill_assistant.proto.CollectUserDataProto
import org.chromium.chrome.browser.autofill_assistant.proto.CollectUserDataResultProto;
import org.chromium.chrome.browser.autofill_assistant.proto.ColorProto;
import org.chromium.chrome.browser.autofill_assistant.proto.ComputeValueProto;
import org.chromium.chrome.browser.autofill_assistant.proto.CreateCreditCardResponseProto;
import org.chromium.chrome.browser.autofill_assistant.proto.CreateNestedGenericUiProto;
import org.chromium.chrome.browser.autofill_assistant.proto.CreditCardResponseProto;
import org.chromium.chrome.browser.autofill_assistant.proto.DateFormatProto;
import org.chromium.chrome.browser.autofill_assistant.proto.DateList;
import org.chromium.chrome.browser.autofill_assistant.proto.DateProto;
......@@ -89,6 +93,7 @@ import org.chromium.chrome.browser.autofill_assistant.proto.ElementConditionProt
import org.chromium.chrome.browser.autofill_assistant.proto.EndActionProto;
import org.chromium.chrome.browser.autofill_assistant.proto.EventProto;
import org.chromium.chrome.browser.autofill_assistant.proto.FocusElementProto;
import org.chromium.chrome.browser.autofill_assistant.proto.ForEachProto;
import org.chromium.chrome.browser.autofill_assistant.proto.GenericUserInterfaceProto;
import org.chromium.chrome.browser.autofill_assistant.proto.ImageViewProto;
import org.chromium.chrome.browser.autofill_assistant.proto.InfoPopupProto;
......@@ -156,16 +161,19 @@ public class AutofillAssistantGenericUiTest {
@Rule
public CustomTabActivityTestRule mTestRule = new CustomTabActivityTestRule();
private AutofillAssistantCollectUserDataTestHelper mHelper;
private static final String TEST_PAGE = "/components/test/data/autofill_assistant/html/"
+ "autofill_assistant_target_website.html";
@Before
public void setUp() {
public void setUp() throws Exception {
AutofillAssistantPreferencesUtil.setInitialPreferences(true);
mTestRule.startCustomTabActivityWithIntent(CustomTabsTestUtils.createMinimalCustomTabIntent(
InstrumentationRegistry.getTargetContext(),
mTestRule.getTestServer().getURL(TEST_PAGE)));
mTestRule.getActivity().getScrim().disableAnimationForTesting(true);
mHelper = new AutofillAssistantCollectUserDataTestHelper();
}
private ViewProto createTestImage(String resourceId, String identifier) {
......@@ -255,6 +263,44 @@ public class AutofillAssistantGenericUiTest {
.build();
}
private ViewProto createSimpleTextView(String identifier, String text) {
return (ViewProto) ViewProto.newBuilder()
.setIdentifier(identifier)
.setTextView(TextViewProto.newBuilder().setText(text))
.build();
}
// A simple view that takes its text from the provided model identifier.
private ViewProto createTextModelView(String identifier, String modelIdentifier) {
return (ViewProto) ViewProto.newBuilder()
.setIdentifier(identifier)
.setTextView(TextViewProto.newBuilder().setModelIdentifier(modelIdentifier))
.build();
}
private CallbackProto createAutofillToStringCallback(
String inputModelIdentifier, String resultModelIdentifier, String autofillFormat) {
return (CallbackProto) CallbackProto.newBuilder()
.setComputeValue(
ComputeValueProto.newBuilder()
.setResultModelIdentifier(resultModelIdentifier)
.setToString(
ToStringProto.newBuilder()
.setValue(ValueReferenceProto.newBuilder()
.setModelIdentifier(
inputModelIdentifier))
.setAutofillFormat(
AutofillFormatProto.newBuilder().setPattern(
autofillFormat))))
.build();
}
private ValueReferenceProto createValueReference(String modelIdentifier) {
return (ValueReferenceProto) ValueReferenceProto.newBuilder()
.setModelIdentifier(modelIdentifier)
.build();
}
@Test
@MediumTest
@DisabledTest(message = "crbug.com/1033877")
......@@ -2502,13 +2548,6 @@ public class AutofillAssistantGenericUiTest {
onView(withText("center-aligned text")).check(matches(withTextGravity(Gravity.CENTER)));
}
private ViewProto createSimpleTextView(String identifier, String text) {
return (ViewProto) ViewProto.newBuilder()
.setIdentifier(identifier)
.setTextView(TextViewProto.newBuilder().setText(text))
.build();
}
/**
* Creates and deletes nested UIs. Also tests startup events for nested UIs.
*/
......@@ -2711,4 +2750,407 @@ public class AutofillAssistantGenericUiTest {
tapElement(mTestRule, "touch_area_one");
waitUntilViewMatchesCondition(withText("Prompt"), isCompletelyDisplayed());
}
/**
* Tests a simple for-each loop.
*/
@Test
@MediumTest
public void testForEach() {
// Clicking the view will run a for-each loop that loops over a value and writes the i'th
// value to result_i, and then ends the action.
List<InteractionProto> interactions = new ArrayList<>();
interactions.add(
(InteractionProto) InteractionProto.newBuilder()
.setTriggerEvent(EventProto.newBuilder().setOnViewClicked(
OnViewClickedEventProto.newBuilder().setViewIdentifier(
"clickable_view")))
.addCallbacks(CallbackProto.newBuilder().setForEach(
ForEachProto.newBuilder()
.setLoopCounter("i")
.setLoopValueModelIdentifier("loop_value")
.addCallbacks(CallbackProto.newBuilder().setSetValue(
SetModelValueProto.newBuilder()
.setModelIdentifier("result_${i}")
.setValue(createValueReference(
"loop_value[${i}]"))))))
.addCallbacks(CallbackProto.newBuilder().setEndAction(
EndActionProto.newBuilder().setStatus(
ProcessedActionStatusProto.ACTION_APPLIED)))
.build());
List<ModelProto.ModelValue> modelValues = new ArrayList<>();
modelValues.add((ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("loop_value")
.setValue(ValueProto.newBuilder().setStrings(
StringList.newBuilder().addAllValues(
Arrays.asList("first", "second", "third"))))
.build());
modelValues.add((ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("result_0")
.build());
modelValues.add((ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("result_1")
.build());
modelValues.add((ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("result_2")
.build());
GenericUserInterfaceProto genericUserInterface =
(GenericUserInterfaceProto) GenericUserInterfaceProto.newBuilder()
.setRootView(ViewProto.newBuilder()
.setIdentifier("clickable_view")
.setTextView(TextViewProto.newBuilder().setText(
"Click me")))
.setInteractions(
InteractionsProto.newBuilder().addAllInteractions(interactions))
.setModel(ModelProto.newBuilder().addAllValues(modelValues))
.build();
ArrayList<ActionProto> list = new ArrayList<>();
list.add((ActionProto) ActionProto.newBuilder()
.setShowGenericUi(ShowGenericUiProto.newBuilder()
.setGenericUserInterface(genericUserInterface)
.addAllOutputModelIdentifiers(Arrays.asList(
"result_0", "result_1", "result_2")))
.build());
AutofillAssistantTestScript script = new AutofillAssistantTestScript(
(SupportedScriptProto) SupportedScriptProto.newBuilder()
.setPath("autofill_assistant_target_website.html")
.setPresentation(PresentationProto.newBuilder().setAutostart(true).setChip(
ChipProto.newBuilder().setText("Autostart")))
.build(),
list);
AutofillAssistantTestService testService =
new AutofillAssistantTestService(Collections.singletonList(script));
startAutofillAssistant(mTestRule.getActivity(), testService);
waitUntilViewMatchesCondition(withText("Click me"), isCompletelyDisplayed());
int numNextActionsCalled = testService.getNextActionsCounter();
onView(withText("Click me")).perform(click());
testService.waitUntilGetNextActions(numNextActionsCalled + 1);
List<ProcessedActionProto> processedActions = testService.getProcessedActions();
assertThat(processedActions, iterableWithSize(1));
assertThat(
processedActions.get(0).getStatus(), is(ProcessedActionStatusProto.ACTION_APPLIED));
ShowGenericUiProto.Result result = processedActions.get(0).getShowGenericUiResult();
List<ModelProto.ModelValue> resultModelValues = result.getModel().getValuesList();
assertThat(resultModelValues, iterableWithSize(3));
assertThat(resultModelValues,
containsInAnyOrder((ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("result_0")
.setValue(ValueProto.newBuilder().setStrings(
StringList.newBuilder().addValues("first")))
.build(),
(ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("result_1")
.setValue(ValueProto.newBuilder().setStrings(
StringList.newBuilder().addValues("second")))
.build(),
(ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("result_2")
.setValue(ValueProto.newBuilder().setStrings(
StringList.newBuilder().addValues("third")))
.build()));
}
/**
* Tests a nested for-each loop.
*/
@Test
@MediumTest
public void testNestedForEach() {
// In pseudo code:
// items = {"first", "second", "third"};
// for (int i = 0; i < items.size(); i++) {
// for (int j = 0; j < items.size(); j++) {
// result_i_j = items[j];
// }
// }
//
// Which should result in:
// result_0_0 = first
// result_0_1 = second
// result_0_2 = third
// result_1_0 = first
// ...
// result_2_2 = third
//
CallbackProto nestedForEach =
(CallbackProto) CallbackProto.newBuilder()
.setForEach(ForEachProto.newBuilder()
.setLoopCounter("j")
.setLoopValueModelIdentifier("loop_value")
.addCallbacks(CallbackProto.newBuilder().setSetValue(
SetModelValueProto.newBuilder()
.setModelIdentifier("result_${i}_${j}")
.setValue(createValueReference(
"loop_value[${j}]")))))
.build();
List<InteractionProto> interactions = new ArrayList<>();
interactions.add((InteractionProto) InteractionProto.newBuilder()
.setTriggerEvent(EventProto.newBuilder().setOnViewClicked(
OnViewClickedEventProto.newBuilder().setViewIdentifier(
"clickable_view")))
.addCallbacks(CallbackProto.newBuilder().setForEach(
ForEachProto.newBuilder()
.setLoopCounter("i")
.setLoopValueModelIdentifier("loop_value")
.addCallbacks(nestedForEach)))
.addCallbacks(CallbackProto.newBuilder().setEndAction(
EndActionProto.newBuilder().setStatus(
ProcessedActionStatusProto.ACTION_APPLIED)))
.build());
List<String> loopValue = Arrays.asList("first", "second", "third");
List<ModelProto.ModelValue> modelValues = new ArrayList<>();
modelValues.add((ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("loop_value")
.setValue(ValueProto.newBuilder().setStrings(
StringList.newBuilder().addAllValues(loopValue)))
.build());
List<String> outputModelIdentifiers = new ArrayList<>();
for (int i = 0; i < loopValue.size(); i++) {
for (int j = 0; j < loopValue.size(); j++) {
String identifier = "result_" + i + "_" + j;
modelValues.add((ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier(identifier)
.build());
outputModelIdentifiers.add(identifier);
}
}
GenericUserInterfaceProto genericUserInterface =
(GenericUserInterfaceProto) GenericUserInterfaceProto.newBuilder()
.setRootView(ViewProto.newBuilder()
.setIdentifier("clickable_view")
.setTextView(TextViewProto.newBuilder().setText(
"Click me")))
.setInteractions(
InteractionsProto.newBuilder().addAllInteractions(interactions))
.setModel(ModelProto.newBuilder().addAllValues(modelValues))
.build();
ArrayList<ActionProto> list = new ArrayList<>();
list.add((ActionProto) ActionProto.newBuilder()
.setShowGenericUi(
ShowGenericUiProto.newBuilder()
.setGenericUserInterface(genericUserInterface)
.addAllOutputModelIdentifiers(outputModelIdentifiers))
.build());
AutofillAssistantTestScript script = new AutofillAssistantTestScript(
(SupportedScriptProto) SupportedScriptProto.newBuilder()
.setPath("autofill_assistant_target_website.html")
.setPresentation(PresentationProto.newBuilder().setAutostart(true).setChip(
ChipProto.newBuilder().setText("Autostart")))
.build(),
list);
AutofillAssistantTestService testService =
new AutofillAssistantTestService(Collections.singletonList(script));
startAutofillAssistant(mTestRule.getActivity(), testService);
waitUntilViewMatchesCondition(withText("Click me"), isCompletelyDisplayed());
int numNextActionsCalled = testService.getNextActionsCounter();
onView(withText("Click me")).perform(click());
testService.waitUntilGetNextActions(numNextActionsCalled + 1);
List<ProcessedActionProto> processedActions = testService.getProcessedActions();
assertThat(processedActions, iterableWithSize(1));
assertThat(
processedActions.get(0).getStatus(), is(ProcessedActionStatusProto.ACTION_APPLIED));
ShowGenericUiProto.Result result = processedActions.get(0).getShowGenericUiResult();
List<ModelProto.ModelValue> resultModelValues = result.getModel().getValuesList();
List<ModelProto.ModelValue> expectedResultValues = new ArrayList<>();
for (int i = 0; i < loopValue.size(); i++) {
for (int j = 0; j < loopValue.size(); j++) {
expectedResultValues.add(
(ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("result_" + i + "_" + j)
.setValue(ValueProto.newBuilder().setStrings(
StringList.newBuilder().addValues(loopValue.get(j))))
.build());
}
}
assertThat(resultModelValues, iterableWithSize(expectedResultValues.size()));
assertThat(resultModelValues, containsInAnyOrder(expectedResultValues.toArray()));
}
/**
* Shows a simple UI (one view per credit card).
*/
@Test
@MediumTest
public void testCreditCardUi() throws Exception {
// Clicking |credit_card_view| will write the current card to |selected_card| and end the
// action.
List<InteractionProto> singleCardInteractions = new ArrayList<>();
singleCardInteractions.add(
(InteractionProto) InteractionProto.newBuilder()
.setTriggerEvent(EventProto.newBuilder().setOnViewClicked(
OnViewClickedEventProto.newBuilder().setViewIdentifier(
"credit_card_view_${i}")))
.addCallbacks(CallbackProto.newBuilder().setSetValue(
SetModelValueProto.newBuilder()
.setModelIdentifier("selected_credit_card")
.setValue(createValueReference("credit_cards[${i}]"))))
.addCallbacks(CallbackProto.newBuilder().setEndAction(
EndActionProto.newBuilder().setStatus(
ProcessedActionStatusProto.ACTION_APPLIED)))
.build());
// For each credit card, a simple UI containing the name and the obfuscated number is
// created.
GenericUserInterfaceProto singleCardUi =
(GenericUserInterfaceProto) GenericUserInterfaceProto.newBuilder()
.setRootView(
ViewProto.newBuilder()
.setIdentifier("credit_card_view_${i}")
.setViewContainer(
ViewContainerProto.newBuilder()
.setLinearLayout(
LinearLayoutProto.newBuilder()
.setOrientation(
LinearLayoutProto
.Orientation
.VERTICAL))
.addViews(createTextModelView(
"card_holder_name_view_${i}",
"card_holder_name_${i}"))
.addViews(createTextModelView(
"obfuscated_number_view_${i}",
"obfuscated_number_${i}"))))
.setInteractions(InteractionsProto.newBuilder().addAllInteractions(
singleCardInteractions))
.build();
// Every time |credit_cards| changes, we:
// - clear any previous card views
// - compute |card_holder_name_${i}| and |obfuscated_number_${i}|
// - re-create card UI
List<InteractionProto> interactions = new ArrayList<>();
interactions.add(
(InteractionProto) InteractionProto.newBuilder()
.setTriggerEvent(EventProto.newBuilder().setOnValueChanged(
OnModelValueChangedEventProto.newBuilder().setModelIdentifier(
"credit_cards")))
.addCallbacks(CallbackProto.newBuilder().setClearViewContainer(
ClearViewContainerProto.newBuilder().setViewIdentifier(
"credit_card_container_view")))
.addCallbacks(CallbackProto.newBuilder().setForEach(
ForEachProto.newBuilder()
.setLoopCounter("i")
.setLoopValueModelIdentifier("credit_cards")
.addCallbacks(
createAutofillToStringCallback("credit_cards[${i}]",
"card_holder_name_${i}", "${51}"))
.addCallbacks(
createAutofillToStringCallback("credit_cards[${i}]",
"obfuscated_number_${i}", "•••• ${-4}"))
.addCallbacks(CallbackProto.newBuilder().setCreateNestedUi(
CreateNestedGenericUiProto.newBuilder()
.setGenericUiIdentifier("nested_ui_${i}")
.setGenericUi(singleCardUi)
.setParentViewIdentifier(
"credit_card_container_view")))))
.build());
// Every time |selected_credit_card| changes, we write the network of the selected card to
// |selected_card_network|, which will be sent back to backend.
interactions.add(
(InteractionProto) InteractionProto.newBuilder()
.setTriggerEvent(EventProto.newBuilder().setOnValueChanged(
OnModelValueChangedEventProto.newBuilder().setModelIdentifier(
"selected_credit_card")))
.addCallbacks(CallbackProto.newBuilder().setComputeValue(
ComputeValueProto.newBuilder()
.setResultModelIdentifier("selected_card_network")
.setCreateCreditCardResponse(
CreateCreditCardResponseProto.newBuilder().setValue(
createValueReference(
"selected_credit_card")))))
.build());
List<ModelProto.ModelValue> modelValues = new ArrayList<>();
modelValues.add((ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("selected_card_network")
.build());
GenericUserInterfaceProto genericUserInterface =
(GenericUserInterfaceProto) GenericUserInterfaceProto.newBuilder()
.setRootView(
ViewProto.newBuilder()
.setIdentifier("credit_card_container_view")
.setViewContainer(
ViewContainerProto.newBuilder().setLinearLayout(
LinearLayoutProto.newBuilder()
.setOrientation(
LinearLayoutProto
.Orientation
.VERTICAL))))
.setInteractions(
InteractionsProto.newBuilder().addAllInteractions(interactions))
.setModel(ModelProto.newBuilder().addAllValues(modelValues))
.build();
ArrayList<ActionProto> list = new ArrayList<>();
list.add((ActionProto) ActionProto.newBuilder()
.setShowGenericUi(
ShowGenericUiProto.newBuilder()
.setGenericUserInterface(genericUserInterface)
.setRequestCreditCards(
ShowGenericUiProto.RequestAutofillCreditCards
.newBuilder()
.setModelIdentifier("credit_cards"))
.addOutputModelIdentifiers("selected_card_network"))
.build());
AutofillAssistantTestScript script = new AutofillAssistantTestScript(
(SupportedScriptProto) SupportedScriptProto.newBuilder()
.setPath("autofill_assistant_target_website.html")
.setPresentation(PresentationProto.newBuilder().setAutostart(true).setChip(
ChipProto.newBuilder().setText("Autostart")))
.build(),
list);
mHelper.addDummyCreditCard(mHelper.addDummyProfile("John Doe", "johndoe@google.com"));
mHelper.addDummyCreditCard(mHelper.addDummyProfile("Jane Doe", "janedoe@google.com"));
AutofillAssistantTestService testService =
new AutofillAssistantTestService(Collections.singletonList(script));
startAutofillAssistant(mTestRule.getActivity(), testService);
waitUntilViewMatchesCondition(withText("John Doe"), isCompletelyDisplayed());
onView(withText("Jane Doe")).check(matches(isDisplayed()));
onView(allOf(withText(containsString("1111")), hasSibling(withText("John Doe"))))
.check(matches(isDisplayed()));
onView(allOf(withText(containsString("1111")), hasSibling(withText("Jane Doe"))))
.check(matches(isDisplayed()));
int numNextActionsCalled = testService.getNextActionsCounter();
onView(withText("Jane Doe")).perform(click());
testService.waitUntilGetNextActions(numNextActionsCalled + 1);
List<ProcessedActionProto> processedActions = testService.getProcessedActions();
assertThat(processedActions, iterableWithSize(1));
assertThat(
processedActions.get(0).getStatus(), is(ProcessedActionStatusProto.ACTION_APPLIED));
ShowGenericUiProto.Result result = processedActions.get(0).getShowGenericUiResult();
List<ModelProto.ModelValue> resultModelValues = result.getModel().getValuesList();
assertThat(resultModelValues, iterableWithSize(1));
assertThat(resultModelValues,
containsInAnyOrder(
(ModelProto.ModelValue) ModelProto.ModelValue.newBuilder()
.setIdentifier("selected_card_network")
.setValue(ValueProto.newBuilder().setCreditCardResponse(
CreditCardResponseProto.newBuilder().setNetwork("visa")))
.build()));
}
}
......@@ -489,16 +489,17 @@ GenericUiControllerAndroid::~GenericUiControllerAndroid() {
std::unique_ptr<GenericUiControllerAndroid>
GenericUiControllerAndroid::CreateFromProto(
const GenericUserInterfaceProto& proto,
const std::map<std::string, std::string> context,
base::android::ScopedJavaGlobalRef<jobject> jcontext,
base::android::ScopedJavaGlobalRef<jobject> jdelegate,
EventHandler* event_handler,
UserModel* user_model,
BasicInteractions* basic_interactions) {
// Create view layout.
auto view_handler = std::make_unique<ViewHandlerAndroid>();
auto view_handler = std::make_unique<ViewHandlerAndroid>(context);
auto interaction_handler = std::make_unique<InteractionHandlerAndroid>(
event_handler, user_model, basic_interactions, view_handler.get(),
jcontext, jdelegate);
context, event_handler, user_model, basic_interactions,
view_handler.get(), jcontext, jdelegate);
JNIEnv* env = base::android::AttachCurrentThread();
auto jroot_view =
proto.has_root_view()
......
......@@ -28,6 +28,7 @@ class GenericUiControllerAndroid {
// Ownership of the arguments is not changed.
static std::unique_ptr<GenericUiControllerAndroid> CreateFromProto(
const GenericUserInterfaceProto& proto,
const std::map<std::string, std::string> context,
base::android::ScopedJavaGlobalRef<jobject> jcontext,
base::android::ScopedJavaGlobalRef<jobject> jdelegate,
EventHandler* event_handler,
......
......@@ -14,6 +14,7 @@
#include "chrome/browser/android/autofill_assistant/generic_ui_interactions_android.h"
#include "chrome/browser/android/autofill_assistant/view_handler_android.h"
#include "components/autofill_assistant/browser/basic_interactions.h"
#include "components/autofill_assistant/browser/field_formatter.h"
#include "components/autofill_assistant/browser/generic_ui.pb.h"
#include "components/autofill_assistant/browser/ui_delegate.h"
#include "components/autofill_assistant/browser/user_model.h"
......@@ -21,14 +22,113 @@
namespace autofill_assistant {
namespace {
// Helper RAII class that sets the execution context for callbacks and unsets
// the context upon deletion. Simply unsetting the context after running the
// callbacks is unsafe, as a callback may have ended the action, thus deleting
// the context and leading to a crash.
class SetExecutionContext {
public:
SetExecutionContext(base::WeakPtr<UserModel> user_model,
base::WeakPtr<ViewHandlerAndroid> view_handler,
const std::map<std::string, std::string>& context)
: user_model_(user_model),
view_handler_(view_handler),
context_(context) {
if (user_model_ != nullptr) {
user_model_->AddIdentifierPlaceholders(context_);
}
if (view_handler_ != nullptr) {
view_handler_->AddIdentifierPlaceholders(context_);
}
}
~SetExecutionContext() {
if (user_model_ != nullptr) {
user_model_->RemoveIdentifierPlaceholders(context_);
}
if (view_handler_ != nullptr) {
view_handler_->RemoveIdentifierPlaceholders(context_);
}
}
private:
base::WeakPtr<UserModel> user_model_;
base::WeakPtr<ViewHandlerAndroid> view_handler_;
std::map<std::string, std::string> context_;
};
// Runs |callbacks| using the context provided by |interaction_handler| and
// |additional_context|.
// Note: parameters are passed by value, as their owner may go out of scope
// before all callbacks have been processed.
void RunWithContext(
std::vector<InteractionHandlerAndroid::InteractionCallback> callbacks,
std::map<std::string, std::string> additional_context,
base::WeakPtr<InteractionHandlerAndroid> interaction_handler,
base::WeakPtr<UserModel> user_model,
base::WeakPtr<ViewHandlerAndroid> view_handler) {
if (!interaction_handler || !user_model || !view_handler) {
return;
}
// Context is set via RAII to ensure that it is properly unset when done.
interaction_handler->AddContext(additional_context);
SetExecutionContext set_context(user_model, view_handler,
interaction_handler->GetContext());
for (const auto& callback : callbacks) {
callback.Run();
// A callback may have caused |interaction_handler| to go out of scope.
if (!interaction_handler) {
return;
}
}
if (interaction_handler != nullptr) {
interaction_handler->RemoveContext(additional_context);
}
}
void RunForEachLoop(
const ForEachProto& proto,
const std::vector<InteractionHandlerAndroid::InteractionCallback>&
callbacks,
base::WeakPtr<InteractionHandlerAndroid> interaction_handler,
base::WeakPtr<UserModel> user_model,
base::WeakPtr<ViewHandlerAndroid> view_handler) {
if (!interaction_handler || !user_model || !view_handler) {
return;
}
auto loop_value = user_model->GetValue(proto.loop_value_model_identifier());
if (!loop_value.has_value()) {
VLOG(2) << "Error running ForEach loop: "
<< proto.loop_value_model_identifier() << " not found in model";
return;
}
for (int i = 0; i < GetValueSize(*loop_value); ++i) {
// Temporarily add "<loop_counter> -> i" to execution context.
// Note: interactions may create nested UI instances. Those instances
// will inherit their parents' current context, which includes the
// placeholder for the loop variable currently being iterated.
RunWithContext(callbacks, /* additional_context = */
{{proto.loop_counter(), base::NumberToString(i)}},
interaction_handler, user_model, view_handler);
}
}
} // namespace
InteractionHandlerAndroid::InteractionHandlerAndroid(
const std::map<std::string, std::string>& context,
EventHandler* event_handler,
UserModel* user_model,
BasicInteractions* basic_interactions,
ViewHandlerAndroid* view_handler,
base::android::ScopedJavaGlobalRef<jobject> jcontext,
base::android::ScopedJavaGlobalRef<jobject> jdelegate)
: event_handler_(event_handler),
: context_(context),
event_handler_(event_handler),
user_model_(user_model),
basic_interactions_(basic_interactions),
view_handler_(view_handler),
......@@ -54,6 +154,20 @@ void InteractionHandlerAndroid::StopListening() {
is_listening_ = false;
}
void InteractionHandlerAndroid::AddContext(
const std::map<std::string, std::string>& context) {
for (const auto& value : context) {
context_[value.first] = value.second;
}
}
void InteractionHandlerAndroid::RemoveContext(
const std::map<std::string, std::string>& context) {
for (const auto& value : context) {
context_.erase(value.first);
}
}
UserModel* InteractionHandlerAndroid::GetUserModel() const {
return user_model_;
}
......@@ -101,9 +215,11 @@ void InteractionHandlerAndroid::AddInteraction(
void InteractionHandlerAndroid::OnEvent(const EventHandler::EventKey& key) {
auto it = interactions_.find(key);
if (it != interactions_.end()) {
for (auto& callback : it->second) {
callback.Run();
}
RunWithContext(it->second, /* additional_context = */ {},
this->GetWeakPtr(), user_model_->GetWeakPtr(),
view_handler_->GetWeakPtr());
// Note: it is unsafe to call any code after running callbacks, because
// a callback may effectively delete *this.
}
}
......@@ -277,13 +393,45 @@ InteractionHandlerAndroid::CreateInteractionCallbackFromProto(
base::BindRepeating(&android_interactions::ClearViewContainer,
proto.clear_view_container().view_identifier(),
view_handler_, jdelegate_));
case CallbackProto::kForEach: {
if (proto.for_each().loop_counter().empty()) {
VLOG(1) << "Error creating ForEach interaction: "
"loop_counter not set";
return base::nullopt;
}
if (proto.for_each().loop_value_model_identifier().empty()) {
VLOG(1) << "Error creating ForEach interaction: "
"loop_value_model_identifier not set";
return base::nullopt;
}
std::vector<InteractionHandlerAndroid::InteractionCallback> callbacks;
for (const auto& callback_proto : proto.for_each().callbacks()) {
auto callback = CreateInteractionCallbackFromProto(callback_proto);
if (!callback.has_value()) {
VLOG(1) << "Error creating ForEach interaction: failed to create "
"callback";
return base::nullopt;
}
callbacks.emplace_back(*callback);
}
return base::Optional<InteractionCallback>(base::BindRepeating(
&RunForEachLoop, proto.for_each(), callbacks, GetWeakPtr(),
user_model_->GetWeakPtr(), view_handler_->GetWeakPtr()));
}
case CallbackProto::KIND_NOT_SET:
VLOG(1) << "Error creating interaction: kind not set";
return base::nullopt;
}
}
void InteractionHandlerAndroid::DeleteNestedUi(const std::string& identifier) {
void InteractionHandlerAndroid::DeleteNestedUi(const std::string& input) {
// Replace all placeholders in the input.
auto formatted_identifier = field_formatter::FormatString(input, context_);
if (!formatted_identifier.has_value()) {
VLOG(2) << "Error deleting nested UI: placeholder not found for " << input;
return;
}
std::string identifier = *formatted_identifier;
auto it = nested_ui_controllers_.find(identifier);
if (it != nested_ui_controllers_.end()) {
nested_ui_controllers_.erase(it);
......@@ -292,7 +440,14 @@ void InteractionHandlerAndroid::DeleteNestedUi(const std::string& identifier) {
const GenericUiControllerAndroid* InteractionHandlerAndroid::CreateNestedUi(
const GenericUserInterfaceProto& proto,
const std::string& identifier) {
const std::string& input) {
// Replace all placeholders in the input.
auto formatted_identifier = field_formatter::FormatString(input, context_);
if (!formatted_identifier.has_value()) {
VLOG(2) << "Error creating nested UI: placeholder not found for " << input;
return nullptr;
}
std::string identifier = *formatted_identifier;
if (nested_ui_controllers_.find(identifier) != nested_ui_controllers_.end()) {
VLOG(2) << "Error creating nested UI: " << identifier
<< " already exixsts (did you forget to clear the previous "
......@@ -300,7 +455,7 @@ const GenericUiControllerAndroid* InteractionHandlerAndroid::CreateNestedUi(
return nullptr;
}
auto nested_ui = GenericUiControllerAndroid::CreateFromProto(
proto, jcontext_, jdelegate_, event_handler_, user_model_,
proto, context_, jcontext_, jdelegate_, event_handler_, user_model_,
basic_interactions_);
const auto* nested_ui_ptr = nested_ui.get();
if (nested_ui) {
......
......@@ -36,6 +36,7 @@ class InteractionHandlerAndroid : public EventHandler::Observer {
// Constructor. All dependencies must outlive this instance.
InteractionHandlerAndroid(
const std::map<std::string, std::string>& context,
EventHandler* event_handler,
UserModel* user_model,
BasicInteractions* basic_interactions,
......@@ -49,6 +50,15 @@ class InteractionHandlerAndroid : public EventHandler::Observer {
void StartListening();
void StopListening();
// Adds |context| to the current context of this interaction handler.
void AddContext(const std::map<std::string, std::string>& context);
// Removes the keys in |context| from this handler's context.
void RemoveContext(const std::map<std::string, std::string>& context);
// Returns a copy of the current context.
std::map<std::string, std::string> GetContext() const { return context_; }
// Access to the user model that this interaction handler is bound to.
UserModel* GetUserModel() const;
......@@ -103,6 +113,11 @@ class InteractionHandlerAndroid : public EventHandler::Observer {
std::map<EventHandler::EventKey, std::vector<InteractionCallback>>
interactions_;
// These key-value pairs specify context variables that the handler will use
// to resolve views and values. Nested instances will inherit their parents'
// context variables. Special interactions, such as ForEach, may modify the
// context while they are being executed.
std::map<std::string, std::string> context_;
EventHandler* event_handler_ = nullptr;
UserModel* user_model_ = nullptr;
BasicInteractions* basic_interactions_ = nullptr;
......
......@@ -1691,7 +1691,8 @@ UiControllerAndroid::CreateGenericUiControllerForProto(
auto jcontext =
Java_AutofillAssistantUiController_getContext(env, java_object_);
return GenericUiControllerAndroid::CreateFromProto(
proto, base::android::ScopedJavaGlobalRef<jobject>(jcontext),
proto, /* context = */ {},
base::android::ScopedJavaGlobalRef<jobject>(jcontext),
generic_ui_delegate_.GetJavaObject(), ui_delegate_->GetEventHandler(),
ui_delegate_->GetUserModel(), ui_delegate_->GetBasicInteractions());
}
......
......@@ -3,14 +3,28 @@
// found in the LICENSE file.
#include "chrome/browser/android/autofill_assistant/view_handler_android.h"
#include "components/autofill_assistant/browser/field_formatter.h"
namespace autofill_assistant {
ViewHandlerAndroid::ViewHandlerAndroid() = default;
ViewHandlerAndroid::ViewHandlerAndroid(
const std::map<std::string, std::string>& identifier_placeholders)
: identifier_placeholders_(identifier_placeholders) {}
ViewHandlerAndroid::~ViewHandlerAndroid() = default;
base::WeakPtr<ViewHandlerAndroid> ViewHandlerAndroid::GetWeakPtr() {
return weak_ptr_factory_.GetWeakPtr();
}
base::Optional<base::android::ScopedJavaGlobalRef<jobject>>
ViewHandlerAndroid::GetView(const std::string& view_identifier) const {
ViewHandlerAndroid::GetView(const std::string& input) const {
// Replace all placeholders in the input.
auto formatted_identifier =
field_formatter::FormatString(input, identifier_placeholders_);
if (!formatted_identifier.has_value()) {
return base::nullopt;
}
std::string view_identifier = *formatted_identifier;
auto it = views_.find(view_identifier);
if (it == views_.end()) {
return base::nullopt;
......@@ -20,10 +34,31 @@ ViewHandlerAndroid::GetView(const std::string& view_identifier) const {
// Adds a view to the set of managed views.
void ViewHandlerAndroid::AddView(
const std::string& view_identifier,
const std::string& input,
base::android::ScopedJavaGlobalRef<jobject> jview) {
// Replace all placeholders in the input.
auto formatted_identifier =
field_formatter::FormatString(input, identifier_placeholders_);
if (!formatted_identifier.has_value()) {
return;
}
std::string view_identifier = *formatted_identifier;
DCHECK(views_.find(view_identifier) == views_.end());
views_.emplace(view_identifier, jview);
}
void ViewHandlerAndroid::AddIdentifierPlaceholders(
const std::map<std::string, std::string> placeholders) {
for (const auto& placeholder : placeholders) {
identifier_placeholders_[placeholder.first] = placeholder.second;
}
}
void ViewHandlerAndroid::RemoveIdentifierPlaceholders(
const std::map<std::string, std::string> placeholders) {
for (const auto& placeholder : placeholders) {
identifier_placeholders_.erase(placeholder.first);
}
}
} // namespace autofill_assistant
......@@ -10,6 +10,7 @@
#include <string>
#include "base/android/jni_android.h"
#include "base/memory/weak_ptr.h"
#include "base/optional.h"
namespace autofill_assistant {
......@@ -17,13 +18,18 @@ namespace autofill_assistant {
// Manages a map of view-identifier -> android view instances.
class ViewHandlerAndroid {
public:
ViewHandlerAndroid();
explicit ViewHandlerAndroid(
const std::map<std::string, std::string>& identifier_placeholders);
~ViewHandlerAndroid();
ViewHandlerAndroid(const ViewHandlerAndroid&) = delete;
ViewHandlerAndroid& operator=(const ViewHandlerAndroid&) = delete;
base::WeakPtr<ViewHandlerAndroid> GetWeakPtr();
// Returns the view associated with |view_identifier| or base::nullopt if
// there is no such view.
// -Placeholders in |view_identifier| of the form ${key} are automatically
// replaced (see |AddIdentifierPlaceholders|).
base::Optional<base::android::ScopedJavaGlobalRef<jobject>> GetView(
const std::string& view_identifier) const;
......@@ -31,8 +37,21 @@ class ViewHandlerAndroid {
void AddView(const std::string& view_identifier,
base::android::ScopedJavaGlobalRef<jobject> jview);
// Adds a set of placeholders (overwrite if necessary). When looking up views
// by identifier, all occurrences of ${key} are automatically replaced by
// their value. Example: the current set of placeholders contains "i" -> "1".
// Looking up the view "view_${i}" will now actually lookup "view_1".
void AddIdentifierPlaceholders(
const std::map<std::string, std::string> placeholders);
// Removes a set of placeholders.
void RemoveIdentifierPlaceholders(
const std::map<std::string, std::string> placeholders);
private:
std::map<std::string, base::android::ScopedJavaGlobalRef<jobject>> views_;
std::map<std::string, std::string> identifier_placeholders_;
base::WeakPtrFactory<ViewHandlerAndroid> weak_ptr_factory_{this};
};
} // namespace autofill_assistant
......
......@@ -51,6 +51,7 @@ message CallbackProto {
ShowGenericUiPopupProto show_generic_popup = 13;
CreateNestedGenericUiProto create_nested_ui = 14;
ClearViewContainerProto clear_view_container = 15;
ForEachProto for_each = 16;
}
// Optional model identifier pointing to a single boolean. If set, the
// callback will only be invoked if the condition is true.
......@@ -392,3 +393,18 @@ message ClearViewContainerProto {
// The view container to clear.
optional string view_identifier = 1;
}
// Invokes |callbacks| for each item in the loop value. Automatically replaces
// instances of "${i}" in model and view identifiers with the loop counter.
message ForEachProto {
// The loop counter, usually "i", "j", etc. Callbacks may use this counter
// in view and model identifiers by using "${i}"" placeholders, e.g.,
// "profiles[${i}]" or "my_view_${i}".
optional string loop_counter = 1;
// The value list to loop over.
optional string loop_value_model_identifier = 2;
// The callbacks to invoke for every iteration of the loop.
repeated CallbackProto callbacks = 3;
}
......@@ -3,7 +3,9 @@
// found in the LICENSE file.
#include "components/autofill_assistant/browser/user_model.h"
#include "components/autofill_assistant/browser/field_formatter.h"
#include "base/logging.h"
#include "base/strings/string_number_conversions.h"
#include "base/strings/string_util.h"
#include "third_party/re2/src/re2/re2.h"
......@@ -55,9 +57,17 @@ base::WeakPtr<UserModel> UserModel::GetWeakPtr() {
return weak_ptr_factory_.GetWeakPtr();
}
void UserModel::SetValue(const std::string& identifier,
void UserModel::SetValue(const std::string& input,
const ValueProto& value,
bool force_notification) {
// Replace all placeholders in the input.
auto formatted_identifier =
field_formatter::FormatString(input, identifier_placeholders_);
if (!formatted_identifier.has_value()) {
VLOG(2) << "Error setting value: placeholder not found for " << input;
return;
}
std::string identifier = *formatted_identifier;
auto result = values_.emplace(identifier, value);
if (!force_notification && !result.second && result.first->second == value &&
value.is_client_side_only() ==
......@@ -72,8 +82,14 @@ void UserModel::SetValue(const std::string& identifier,
}
}
base::Optional<ValueProto> UserModel::GetValue(
const std::string& identifier) const {
base::Optional<ValueProto> UserModel::GetValue(const std::string& input) const {
// Replace all placeholders in the input.
auto formatted_identifier =
field_formatter::FormatString(input, identifier_placeholders_);
if (!formatted_identifier.has_value()) {
return base::nullopt;
}
std::string identifier = *formatted_identifier;
auto it = values_.find(identifier);
if (it != values_.end()) {
return it->second;
......@@ -145,6 +161,20 @@ void UserModel::RemoveObserver(Observer* observer) {
observers_.RemoveObserver(observer);
}
void UserModel::AddIdentifierPlaceholders(
const std::map<std::string, std::string> placeholders) {
for (const auto& placeholder : placeholders) {
identifier_placeholders_[placeholder.first] = placeholder.second;
}
}
void UserModel::RemoveIdentifierPlaceholders(
const std::map<std::string, std::string> placeholders) {
for (const auto& placeholder : placeholders) {
identifier_placeholders_.erase(placeholder.first);
}
}
void UserModel::SetAutofillCreditCards(
std::unique_ptr<std::vector<std::unique_ptr<autofill::CreditCard>>>
credit_cards) {
......
......@@ -48,8 +48,10 @@ class UserModel {
bool force_notification = false);
// Returns the value for |identifier| or nullopt if there is no such value.
// Also supports the array operator to retrieve a specific element of a list,
// e.g., "identifier[0]" to get the first item.
// - Placeholders in |identifier| of the form ${key} are automatically
// replaced (see |AddIdentifierPlaceholders|).
// - Also supports the array operator to retrieve
// a specific element of a list, e.g., "identifier[0]" to get the first item.
base::Optional<ValueProto> GetValue(const std::string& identifier) const;
// Returns the value for |reference| or nullopt if there is no such value.
......@@ -72,6 +74,17 @@ class UserModel {
return values;
}
// Adds a set of placeholders (overwrite if necessary). When looking up values
// by identifier, all occurrences of ${key} are automatically replaced by
// their value. Example: the current set of placeholders contains "i" -> "1".
// Looking up the value "value[${i}]" will now actually lookup "value[1]".
void AddIdentifierPlaceholders(
const std::map<std::string, std::string> placeholders);
// Removes a set of placeholders.
void RemoveIdentifierPlaceholders(
const std::map<std::string, std::string> placeholders);
// Replaces the set of available autofill credit cards.
void SetAutofillCreditCards(
std::unique_ptr<std::vector<std::unique_ptr<autofill::CreditCard>>>
......@@ -105,6 +118,7 @@ class UserModel {
friend class UserModelTest;
std::map<std::string, ValueProto> values_;
std::map<std::string, std::string> identifier_placeholders_;
std::map<std::string, std::unique_ptr<autofill::CreditCard>> credit_cards_;
std::map<std::string, std::unique_ptr<autofill::AutofillProfile>> profiles_;
base::ObserverList<Observer> observers_;
......
......@@ -366,4 +366,75 @@ TEST_F(UserModelTest, ClientSideOnlyNotifications) {
EXPECT_TRUE(GetValues().at("identifier").is_client_side_only());
}
TEST_F(UserModelTest, GetValueWithPlaceholders) {
ValueProto value;
value.mutable_strings()->add_values("a");
value.mutable_strings()->add_values("b");
value.mutable_strings()->add_values("c");
model_.SetValue("multi_value", value);
model_.SetValue("single_value_0", SimpleValue(std::string("d")));
model_.SetValue("single_value_1", SimpleValue(std::string("e")));
model_.SetValue("single_value_2", SimpleValue(std::string("f")));
EXPECT_EQ(model_.GetValue("multi_value[${i}]"), base::nullopt);
EXPECT_EQ(model_.GetValue("single_value_i"), base::nullopt);
model_.AddIdentifierPlaceholders({{"i", "0"}});
EXPECT_EQ(model_.GetValue("multi_value[${i}]"),
SimpleValue(std::string("a")));
EXPECT_EQ(model_.GetValue("single_value_${i}"),
SimpleValue(std::string("d")));
// Add placeholder.
model_.AddIdentifierPlaceholders({{"j", "1"}});
EXPECT_EQ(model_.GetValue("multi_value[${j}]"),
SimpleValue(std::string("b")));
EXPECT_EQ(model_.GetValue("single_value_${j}"),
SimpleValue(std::string("e")));
EXPECT_EQ(model_.GetValue("single_value_${j}[${i}]"),
SimpleValue(std::string("e")));
// Overwrite placeholder.
model_.AddIdentifierPlaceholders({{"i", "2"}});
EXPECT_EQ(model_.GetValue("multi_value[${i}]"),
SimpleValue(std::string("c")));
EXPECT_EQ(model_.GetValue("single_value_${i}"),
SimpleValue(std::string("f")));
EXPECT_EQ(model_.GetValue("single_value_${j}[${i}]"), base::nullopt);
// Remove placeholder (the value does not matter, it's just about the key).
model_.RemoveIdentifierPlaceholders({{"i", "123"}});
EXPECT_EQ(model_.GetValue("multi_value[${i}]"), base::nullopt);
EXPECT_EQ(model_.GetValue("single_value_${i}"), base::nullopt);
EXPECT_EQ(model_.GetValue("single_value_${j}"),
SimpleValue(std::string("e")));
}
TEST_F(UserModelTest, SetValueWithPlaceholders) {
ValueProto value;
value.mutable_strings()->add_values("a");
value.mutable_strings()->add_values("b");
value.mutable_strings()->add_values("c");
model_.SetValue("value_${i}", value);
EXPECT_EQ(model_.GetValue("value_${i}"), base::nullopt);
model_.AddIdentifierPlaceholders({{"i", "0"}});
model_.SetValue("value_${i}", value);
EXPECT_EQ(model_.GetValue("value_0"), value);
EXPECT_EQ(model_.GetValue("value_${i}"), value);
model_.RemoveIdentifierPlaceholders({{"i", "0"}});
EXPECT_EQ(model_.GetValue("value_0"), value);
EXPECT_EQ(model_.GetValue("value_${i}"), base::nullopt);
model_.AddIdentifierPlaceholders({{"i", "0"}});
model_.AddIdentifierPlaceholders({{"j", "1"}});
model_.SetValue("value_${i}_${j}", value);
EXPECT_EQ(model_.GetValue("value_0_1"), value);
EXPECT_EQ(model_.GetValue("value_${i}_${j}"), value);
model_.RemoveIdentifierPlaceholders({{"j", "1"}});
EXPECT_EQ(model_.GetValue("value_${i}_${j}"), base::nullopt);
model_.SetValue("value_${i}", value);
EXPECT_EQ(model_.GetValue("value_${i}"), value);
}
} // namespace autofill_assistant
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