/* * Copyright (C) 2019 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package foo.bar.inline; import android.app.PendingIntent; import android.app.assist.AssistStructure; import android.app.assist.AssistStructure.ViewNode; import android.app.slice.Slice; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.IntentSender; import android.graphics.drawable.Icon; import android.os.CancellationSignal; import android.service.autofill.AutofillService; import android.service.autofill.Dataset; import android.service.autofill.FillCallback; import android.service.autofill.FillContext; import android.service.autofill.FillRequest; import android.service.autofill.FillResponse; import android.service.autofill.InlinePresentation; import android.service.autofill.SaveCallback; import android.service.autofill.SaveInfo; import android.service.autofill.SaveRequest; import android.util.ArrayMap; import android.util.Log; import android.util.Size; import android.view.autofill.AutofillId; import android.view.autofill.AutofillValue; import android.widget.inline.InlinePresentationSpec; import android.view.inputmethod.InlineSuggestionsRequest; import android.widget.RemoteViews; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.autofill.InlinePresentationBuilder; import java.util.Collection; import java.util.List; import java.util.Map; import java.util.Map.Entry; /** * A basic {@link AutofillService} implementation that only shows dynamic-generated datasets * and supports inline suggestions. */ public class InlineFillService extends AutofillService { private static final String TAG = "InlineFillService"; /** * Number of datasets sent on each request - we're simple, that value is hardcoded in our DNA! */ static final int NUMBER_DATASETS = 6; private final boolean mAuthenticateResponses = false; private final boolean mAuthenticateDatasets = false; @Override public void onFillRequest(FillRequest request, CancellationSignal cancellationSignal, FillCallback callback) { Log.d(TAG, "onFillRequest()"); // Find autofillable fields AssistStructure structure = getLatestAssistStructure(request); ArrayMap fields = getAutofillableFields(structure, request.getFlags()); Log.d(TAG, "autofillable fields:" + fields); if (fields.isEmpty()) { showMessage("Service could not figure out how to autofill this screen"); callback.onSuccess(null); return; } final InlineSuggestionsRequest inlineRequest = request.getInlineSuggestionsRequest(); final int maxSuggestionsCount = inlineRequest == null ? NUMBER_DATASETS : Math.min(inlineRequest.getMaxSuggestionCount(), NUMBER_DATASETS); // Create the base response final FillResponse response; if (mAuthenticateResponses) { int size = fields.size(); String[] hints = new String[size]; AutofillId[] ids = new AutofillId[size]; for (int i = 0; i < size; i++) { hints[i] = fields.keyAt(i); ids[i] = fields.valueAt(i); } IntentSender authentication = AuthActivity.newIntentSenderForResponse(this, hints, ids, mAuthenticateDatasets, inlineRequest); RemoteViews presentation = newDatasetPresentation(getPackageName(), "Tap to auth response"); final InlinePresentation inlinePresentation; if (inlineRequest != null) { final Slice authSlice = new InlinePresentationBuilder("Tap to auth respones") .build(); final List specs = inlineRequest.getInlinePresentationSpecs(); final int specsSize = specs.size(); final InlinePresentationSpec currentSpec = specsSize > 0 ? specs.get(0) : null; inlinePresentation = new InlinePresentation(authSlice, currentSpec, /* pined= */ false); } else { inlinePresentation = null; } response = new FillResponse.Builder() .setAuthentication(ids, authentication, presentation, inlinePresentation) .build(); } else { response = createResponse(this, fields, maxSuggestionsCount, mAuthenticateDatasets, request.getInlineSuggestionsRequest()); } callback.onSuccess(response); } static FillResponse createResponse(@NonNull Context context, @NonNull ArrayMap fields, int numDatasets, boolean authenticateDatasets, @Nullable InlineSuggestionsRequest inlineRequest) { String packageName = context.getPackageName(); FillResponse.Builder response = new FillResponse.Builder(); // 1.Add the dynamic datasets for (int i = 1; i <= numDatasets; i++) { Dataset unlockedDataset = newUnlockedDataset(context, fields, packageName, i, inlineRequest); if (authenticateDatasets) { Dataset.Builder lockedDataset = new Dataset.Builder(); for (Entry field : fields.entrySet()) { String hint = field.getKey(); AutofillId id = field.getValue(); String value = i + "-" + hint; IntentSender authentication = AuthActivity.newIntentSenderForDataset(context, unlockedDataset); RemoteViews presentation = newDatasetPresentation(packageName, "Tap to auth " + value); final InlinePresentation inlinePresentation; if (inlineRequest != null) { final Slice authSlice = new InlinePresentationBuilder( "Tap to auth " + value).build(); final List specs = inlineRequest.getInlinePresentationSpecs(); final int specsSize = specs.size(); final InlinePresentationSpec currentSpec = specsSize > 0 ? specs.get(0) : null; inlinePresentation = new InlinePresentation(authSlice, currentSpec, /* pined= */ false); lockedDataset.setValue(id, null, presentation, inlinePresentation) .setAuthentication(authentication); } else { lockedDataset.setValue(id, null, presentation) .setAuthentication(authentication); } } response.addDataset(lockedDataset.build()); } else { response.addDataset(unlockedDataset); } } // if (inlineRequest != null) { // // Reuse the first spec's height for the inline action size, as there isn't dedicated // // value from the request for this. // final int height = inlineRequest.getPresentationSpecs().get(0).getMinSize().getHeight(); // final Size actionIconSize = new Size(height, height); // response.addDataset( // newInlineActionDataset(context, actionIconSize, R.drawable.ic_settings, // fields)); // } // 2.Add save info Collection ids = fields.values(); AutofillId[] requiredIds = new AutofillId[ids.size()]; ids.toArray(requiredIds); response.setSaveInfo( // We're simple, so we're generic new SaveInfo.Builder(SaveInfo.SAVE_DATA_TYPE_GENERIC, requiredIds).build()); // 3.Profit! return response.build(); } static Dataset newInlineActionDataset(@NonNull Context context, @NonNull Size size, int drawable, ArrayMap fields) { Intent intent = new Intent().setComponent( new ComponentName(context.getPackageName(), SettingsActivity.class.getName())); PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); final Slice suggestionSlice = new InlinePresentationBuilder() .setStartIcon(Icon.createWithResource(context, drawable)) .setAttribution(pendingIntent) .build(); final InlinePresentationSpec currentSpec = new InlinePresentationSpec.Builder(size, size).build(); Dataset.Builder builder = new Dataset.Builder() .setInlinePresentation( new InlinePresentation(suggestionSlice, currentSpec, /** pined= */true)) .setAuthentication(pendingIntent.getIntentSender()); for (AutofillId fieldId : fields.values()) { builder.setValue(fieldId, null); } return builder.build(); } static Dataset newUnlockedDataset(@NonNull Context context, @NonNull Map fields, @NonNull String packageName, int i, @Nullable InlineSuggestionsRequest inlineRequest) { Dataset.Builder dataset = new Dataset.Builder(); for (Entry field : fields.entrySet()) { final String hint = field.getKey(); final AutofillId id = field.getValue(); final String value = hint + i; // We're simple - our dataset values are hardcoded as "hintN" (for example, // "username1", "username2") and they're displayed as such, except if they're a // password final String displayValue = hint.contains("password") ? "password for #" + i : value; final RemoteViews presentation = newDatasetPresentation(packageName, displayValue); // Add Inline Suggestion required info. if (inlineRequest != null) { Log.d(TAG, "Found InlineSuggestionsRequest in FillRequest: " + inlineRequest); Intent intent = new Intent().setComponent( new ComponentName(context.getPackageName(), SettingsActivity.class.getName())); PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); final Slice suggestionSlice = new InlinePresentationBuilder(value) .setAttribution(pendingIntent).build(); final List specs = inlineRequest.getInlinePresentationSpecs(); final int specsSize = specs.size(); final InlinePresentationSpec currentSpec = i - 1 < specsSize ? specs.get(i - 1) : specs.get(specsSize - 1); final InlinePresentation inlinePresentation = new InlinePresentation(suggestionSlice, currentSpec, /** pined= */false); dataset.setValue(id, AutofillValue.forText(value), presentation, inlinePresentation); } else { dataset.setValue(id, AutofillValue.forText(value), presentation); } } return dataset.build(); } @Override public void onSaveRequest(SaveRequest request, SaveCallback callback) { Log.d(TAG, "onSaveRequest()"); showMessage("Save not supported"); callback.onSuccess(); } /** * Parses the {@link AssistStructure} representing the activity being autofilled, and returns a * map of autofillable fields (represented by their autofill ids) mapped by the hint associate * with them. * *

An autofillable field is a {@link ViewNode} whose getHint(ViewNode) method. */ @NonNull private ArrayMap getAutofillableFields(@NonNull AssistStructure structure, int flags) { ArrayMap fields = new ArrayMap<>(); int nodes = structure.getWindowNodeCount(); for (int i = 0; i < nodes; i++) { ViewNode node = structure.getWindowNodeAt(i).getRootViewNode(); addAutofillableFields(fields, node, flags); } return fields; } /** * Adds any autofillable view from the {@link ViewNode} and its descendants to the map. */ private void addAutofillableFields(@NonNull Map fields, @NonNull ViewNode node, int flags) { int type = node.getAutofillType(); String hint = getHint(node, flags); if (hint != null) { AutofillId id = node.getAutofillId(); if (!fields.containsKey(hint)) { Log.v(TAG, "Setting hint " + hint + " on " + id); fields.put(hint, id); } else { Log.v(TAG, "Ignoring hint " + hint + " on " + id + " because it was already set"); } } int childrenSize = node.getChildCount(); for (int i = 0; i < childrenSize; i++) { addAutofillableFields(fields, node.getChildAt(i), flags); } } /** * Gets the autofill hint associated with the given node. * *

By default it just return the first entry on the node's * {@link ViewNode#getAutofillHints() autofillHints} (when available), but subclasses could * extend it to use heuristics when the app developer didn't explicitly provide these hints. */ @Nullable protected String getHint(@NonNull ViewNode node, int flags) { String[] hints = node.getAutofillHints(); if (hints == null) return null; // We're simple, we only care about the first hint String hint = hints[0].toLowerCase(); return hint; } /** * Helper method to get the {@link AssistStructure} associated with the latest request * in an autofill context. */ @NonNull private static AssistStructure getLatestAssistStructure(@NonNull FillRequest request) { List fillContexts = request.getFillContexts(); return fillContexts.get(fillContexts.size() - 1).getStructure(); } /** * Helper method to create a dataset presentation with the given text. */ @NonNull private static RemoteViews newDatasetPresentation(@NonNull String packageName, @NonNull CharSequence text) { RemoteViews presentation = new RemoteViews(packageName, R.layout.list_item); presentation.setTextViewText(R.id.text, text); return presentation; } /** * Displays a toast with the given message. */ private void showMessage(@NonNull CharSequence message) { Log.i(TAG, message.toString()); Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show(); } }