| /* |
| * Copyright (C) 2018 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 com.example.android.autofill.service.simple; |
| |
| import static com.example.android.autofill.service.simple.BasicService.getLatestAssistStructure; |
| import static com.example.android.autofill.service.simple.BasicService.newDatasetPresentation; |
| |
| import android.app.assist.AssistStructure; |
| import android.app.assist.AssistStructure.ViewNode; |
| import android.content.Context; |
| import android.content.IntentSender; |
| import android.os.CancellationSignal; |
| import android.service.autofill.AutofillService; |
| import android.service.autofill.Dataset; |
| import android.service.autofill.FillCallback; |
| import android.service.autofill.FillRequest; |
| import android.service.autofill.FillResponse; |
| import android.service.autofill.SaveCallback; |
| import android.service.autofill.SaveInfo; |
| import android.service.autofill.SaveRequest; |
| import android.support.annotation.NonNull; |
| import android.support.annotation.Nullable; |
| import android.text.TextUtils; |
| import android.util.ArrayMap; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.autofill.AutofillId; |
| import android.view.autofill.AutofillValue; |
| import android.widget.RemoteViews; |
| import android.widget.Toast; |
| |
| import com.example.android.autofill.service.MyAutofillService; |
| import com.example.android.autofill.service.settings.MyPreferences; |
| |
| import java.util.Collection; |
| import java.util.Map; |
| import java.util.Map.Entry; |
| |
| /** |
| * A basic service that uses some rudimentary heuristics to identify fields that are not explicitly |
| * marked with autofill hints. |
| * |
| * <p>The goal of this class is to provide a simple autofill service implementation that is easy |
| * to understand and extend, but it should <strong>not</strong> be used as-is on real apps because |
| * it lacks fundamental security requirements such as data partitioning and package verification |
| * &mdashthese requirements are fullfilled by {@link MyAutofillService}. |
| */ |
| public class HeuristicsService extends AutofillService { |
| |
| private static final String TAG = "HeuristicsService"; |
| |
| private boolean mAuthenticateResponses; |
| private boolean mAuthenticateDatasets; |
| private int mNumberDatasets = 4; |
| |
| @Override |
| public void onConnected() { |
| super.onConnected(); |
| |
| // TODO(b/114236837): use its own preferences? |
| MyPreferences pref = MyPreferences.getInstance(getApplicationContext()); |
| mAuthenticateResponses = pref.isResponseAuth(); |
| mAuthenticateDatasets = pref.isDatasetAuth(); |
| // TODO(b/114236837): get number dataset from preferences |
| |
| Log.d(TAG, "onConnected(): numberDatasets=" + mNumberDatasets |
| + ", authResponses=" + mAuthenticateResponses |
| + ", authDatasets=" + mAuthenticateDatasets); |
| } |
| |
| @Override |
| public void onFillRequest(FillRequest request, CancellationSignal cancellationSignal, |
| FillCallback callback) { |
| Log.d(TAG, "onFillRequest()"); |
| |
| // Find autofillable fields |
| AssistStructure structure = getLatestAssistStructure(request); |
| ArrayMap<String, AutofillId> fields = getAutofillableFields(structure); |
| Log.d(TAG, "autofillable fields:" + fields); |
| |
| if (fields.isEmpty()) { |
| toast("No autofill hints found"); |
| callback.onSuccess(null); |
| return; |
| } |
| |
| // Create response... |
| 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 = SimpleAuthActivity.newIntentSenderForResponse(this, hints, |
| ids, mAuthenticateDatasets); |
| RemoteViews presentation = newDatasetPresentation(getPackageName(), |
| "Tap to auth response"); |
| |
| response = new FillResponse.Builder() |
| .setAuthentication(ids, authentication, presentation).build(); |
| } else { |
| response = createResponse(this, fields, mNumberDatasets,mAuthenticateDatasets); |
| } |
| |
| // ... and return it |
| callback.onSuccess(response); |
| } |
| |
| @Override |
| public void onSaveRequest(SaveRequest request, SaveCallback callback) { |
| Log.d(TAG, "onSaveRequest()"); |
| toast("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. |
| * |
| * <p>An autofillable field is a {@link ViewNode} whose {@link #getHint(ViewNode)} metho |
| */ |
| @NonNull |
| private ArrayMap<String, AutofillId> getAutofillableFields(@NonNull AssistStructure structure) { |
| ArrayMap<String, AutofillId> fields = new ArrayMap<>(); |
| int nodes = structure.getWindowNodeCount(); |
| for (int i = 0; i < nodes; i++) { |
| ViewNode node = structure.getWindowNodeAt(i).getRootViewNode(); |
| addAutofillableFields(fields, node); |
| } |
| return fields; |
| } |
| |
| /** |
| * Adds any autofillable view from the {@link ViewNode} and its descendants to the map. |
| */ |
| private void addAutofillableFields(@NonNull Map<String, AutofillId> fields, |
| @NonNull ViewNode node) { |
| String hint = getHint(node); |
| 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)); |
| } |
| } |
| |
| @Nullable |
| protected String getHint(@NonNull ViewNode node) { |
| |
| // First try the explicit autofill hints... |
| |
| String[] hints = node.getAutofillHints(); |
| if (hints != null) { |
| // We're simple, we only care about the first hint |
| return hints[0].toLowerCase(); |
| } |
| |
| // Then try some rudimentary heuristics based on other node properties |
| |
| String viewHint = node.getHint(); |
| String hint = inferHint(viewHint); |
| if (hint != null) { |
| Log.d(TAG, "Found hint using view hint(" + viewHint + "): " + hint); |
| return hint; |
| } else if (!TextUtils.isEmpty(viewHint)) { |
| Log.v(TAG, "No hint using view hint: " + viewHint); |
| } |
| |
| String resourceId = node.getIdEntry(); |
| hint = inferHint(resourceId); |
| if (hint != null) { |
| Log.d(TAG, "Found hint using resourceId(" + resourceId + "): " + hint); |
| return hint; |
| } else if (!TextUtils.isEmpty(resourceId)) { |
| Log.v(TAG, "No hint using resourceId: " + resourceId); |
| } |
| |
| CharSequence text = node.getText(); |
| CharSequence className = node.getClassName(); |
| if (text != null && className != null && className.toString().contains("EditText")) { |
| hint = inferHint(text.toString()); |
| if (hint != null) { |
| // NODE: text should not be logged, as it could contain PII |
| Log.d(TAG, "Found hint using text(" + text + "): " + hint); |
| return hint; |
| } |
| } else if (!TextUtils.isEmpty(text)) { |
| // NODE: text should not be logged, as it could contain PII |
| Log.v(TAG, "No hint using text: " + text + " and class " + className); |
| } |
| return null; |
| } |
| |
| /** |
| * Uses heuristics to infer an autofill hint from a {@code string}. |
| * |
| * @return standard autofill hint, or {@code null} when it could not be inferred. |
| */ |
| @Nullable |
| protected String inferHint(@Nullable String string) { |
| if (string == null) return null; |
| |
| string = string.toLowerCase(); |
| if (string.contains("label")) { |
| Log.v(TAG, "Ignoring 'label' hint: " + string); |
| return null; |
| } |
| if (string.contains("password")) return View.AUTOFILL_HINT_PASSWORD; |
| if (string.contains("username") |
| || (string.contains("login") && string.contains("id"))) |
| return View.AUTOFILL_HINT_USERNAME; |
| if (string.contains("email")) return View.AUTOFILL_HINT_EMAIL_ADDRESS; |
| if (string.contains("name")) return View.AUTOFILL_HINT_NAME; |
| if (string.contains("phone")) return View.AUTOFILL_HINT_PHONE; |
| |
| return null; |
| } |
| |
| static FillResponse createResponse(@NonNull Context context, |
| @NonNull ArrayMap<String, AutofillId> fields, int numDatasets, |
| boolean authenticateDatasets) { |
| 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(fields, packageName, i); |
| if (authenticateDatasets) { |
| Dataset.Builder lockedDataset = new Dataset.Builder(); |
| for (Entry<String, AutofillId> field : fields.entrySet()) { |
| String hint = field.getKey(); |
| AutofillId id = field.getValue(); |
| String value = i + "-" + hint; |
| IntentSender authentication = |
| SimpleAuthActivity.newIntentSenderForDataset(context, unlockedDataset); |
| RemoteViews presentation = newDatasetPresentation(packageName, |
| "Tap to auth " + value); |
| lockedDataset.setValue(id, null, presentation) |
| .setAuthentication(authentication); |
| } |
| response.addDataset(lockedDataset.build()); |
| } else { |
| response.addDataset(unlockedDataset); |
| } |
| } |
| |
| // 2.Add save info |
| Collection<AutofillId> 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 newUnlockedDataset(@NonNull Map<String, AutofillId> fields, |
| @NonNull String packageName, int i) { |
| Dataset.Builder dataset = new Dataset.Builder(); |
| for (Entry<String, AutofillId> field : fields.entrySet()) { |
| String hint = field.getKey(); |
| AutofillId id = field.getValue(); |
| String value = i + "-" + hint; |
| |
| // We're simple - our dataset values are hardcoded as "N-hint" (for example, |
| // "1-username", "2-username") and they're displayed as such, except if they're a |
| // password |
| String displayValue = hint.contains("password") ? "password for #" + i : value; |
| RemoteViews presentation = newDatasetPresentation(packageName, displayValue); |
| dataset.setValue(id, AutofillValue.forText(value), presentation); |
| } |
| |
| return dataset.build(); |
| } |
| |
| /** |
| * Displays a toast with the given message. |
| */ |
| private void toast(@NonNull CharSequence message) { |
| Toast.makeText(getApplicationContext(), message, Toast.LENGTH_LONG).show(); |
| } |
| } |