1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.server.autofill.ui; 18 19 import static com.android.server.autofill.Helper.sDebug; 20 import static com.android.server.autofill.Helper.sVerbose; 21 22 import android.annotation.NonNull; 23 import android.annotation.Nullable; 24 import android.app.Dialog; 25 import android.content.ComponentName; 26 import android.content.Context; 27 import android.content.IntentSender; 28 import android.graphics.drawable.Drawable; 29 import android.service.autofill.Dataset; 30 import android.service.autofill.FillResponse; 31 import android.text.TextUtils; 32 import android.util.PluralsMessageFormatter; 33 import android.util.Slog; 34 import android.view.ContextThemeWrapper; 35 import android.view.Gravity; 36 import android.view.LayoutInflater; 37 import android.view.View; 38 import android.view.ViewGroup; 39 import android.view.Window; 40 import android.view.WindowManager; 41 import android.view.accessibility.AccessibilityManager; 42 import android.view.autofill.AutofillId; 43 import android.view.autofill.AutofillValue; 44 import android.widget.AdapterView; 45 import android.widget.BaseAdapter; 46 import android.widget.Filter; 47 import android.widget.Filterable; 48 import android.widget.ImageView; 49 import android.widget.ListView; 50 import android.widget.RemoteViews; 51 import android.widget.TextView; 52 53 import com.android.internal.R; 54 import com.android.server.autofill.AutofillManagerService; 55 56 import java.io.PrintWriter; 57 import java.util.ArrayList; 58 import java.util.Collections; 59 import java.util.HashMap; 60 import java.util.List; 61 import java.util.Map; 62 import java.util.regex.Pattern; 63 import java.util.stream.Collectors; 64 65 /** 66 * A dialog to show Autofill suggestions. 67 * 68 * This fill dialog UI shows as a bottom sheet style dialog. This dialog UI 69 * provides a larger area to display the suggestions, it provides a more 70 * conspicuous and efficient interface to the user. So it is easy for users 71 * to pay attention to the datasets and selecting one of them. 72 */ 73 final class DialogFillUi { 74 75 private static final String TAG = "DialogFillUi"; 76 private static final int THEME_ID_LIGHT = 77 R.style.Theme_DeviceDefault_Light_Autofill_Save; 78 private static final int THEME_ID_DARK = 79 R.style.Theme_DeviceDefault_Autofill_Save; 80 81 interface UiCallback { onResponsePicked(@onNull FillResponse response)82 void onResponsePicked(@NonNull FillResponse response); onDatasetPicked(@onNull Dataset dataset)83 void onDatasetPicked(@NonNull Dataset dataset); onDismissed()84 void onDismissed(); onCanceled()85 void onCanceled(); startIntentSender(IntentSender intentSender)86 void startIntentSender(IntentSender intentSender); 87 } 88 89 private final @NonNull Dialog mDialog; 90 private final @NonNull OverlayControl mOverlayControl; 91 private final String mServicePackageName; 92 private final ComponentName mComponentName; 93 private final int mThemeId; 94 private final @NonNull Context mContext; 95 private final @NonNull UiCallback mCallback; 96 private final @NonNull ListView mListView; 97 private final @Nullable ItemsAdapter mAdapter; 98 private final int mVisibleDatasetsMaxCount; 99 100 private @Nullable String mFilterText; 101 private @Nullable AnnounceFilterResult mAnnounceFilterResult; 102 private boolean mDestroyed; 103 DialogFillUi(@onNull Context context, @NonNull FillResponse response, @NonNull AutofillId focusedViewId, @Nullable String filterText, @Nullable Drawable serviceIcon, @Nullable String servicePackageName, @Nullable ComponentName componentName, @NonNull OverlayControl overlayControl, boolean nightMode, @NonNull UiCallback callback)104 DialogFillUi(@NonNull Context context, @NonNull FillResponse response, 105 @NonNull AutofillId focusedViewId, @Nullable String filterText, 106 @Nullable Drawable serviceIcon, @Nullable String servicePackageName, 107 @Nullable ComponentName componentName, @NonNull OverlayControl overlayControl, 108 boolean nightMode, @NonNull UiCallback callback) { 109 if (sVerbose) Slog.v(TAG, "nightMode: " + nightMode); 110 mThemeId = nightMode ? THEME_ID_DARK : THEME_ID_LIGHT; 111 mCallback = callback; 112 mOverlayControl = overlayControl; 113 mServicePackageName = servicePackageName; 114 mComponentName = componentName; 115 116 mContext = new ContextThemeWrapper(context, mThemeId); 117 final LayoutInflater inflater = LayoutInflater.from(mContext); 118 final View decor = inflater.inflate(R.layout.autofill_fill_dialog, null); 119 120 setServiceIcon(decor, serviceIcon); 121 setHeader(decor, response); 122 123 mVisibleDatasetsMaxCount = getVisibleDatasetsMaxCount(); 124 125 if (response.getAuthentication() != null) { 126 mListView = null; 127 mAdapter = null; 128 try { 129 initialAuthenticationLayout(decor, response); 130 } catch (RuntimeException e) { 131 callback.onCanceled(); 132 Slog.e(TAG, "Error inflating remote views", e); 133 mDialog = null; 134 return; 135 } 136 } else { 137 final List<ViewItem> items = createDatasetItems(response, focusedViewId); 138 mAdapter = new ItemsAdapter(items); 139 mListView = decor.findViewById(R.id.autofill_dialog_list); 140 initialDatasetLayout(decor, filterText); 141 } 142 143 setDismissButton(decor); 144 145 mDialog = new Dialog(mContext, mThemeId); 146 mDialog.setContentView(decor); 147 setDialogParamsAsBottomSheet(); 148 mDialog.setOnCancelListener((d) -> mCallback.onCanceled()); 149 150 show(); 151 } 152 getVisibleDatasetsMaxCount()153 private int getVisibleDatasetsMaxCount() { 154 if (AutofillManagerService.getVisibleDatasetsMaxCount() > 0) { 155 final int maxCount = AutofillManagerService.getVisibleDatasetsMaxCount(); 156 if (sVerbose) { 157 Slog.v(TAG, "overriding maximum visible datasets to " + maxCount); 158 } 159 return maxCount; 160 } else { 161 return mContext.getResources() 162 .getInteger(com.android.internal.R.integer.autofill_max_visible_datasets); 163 } 164 } 165 setDialogParamsAsBottomSheet()166 private void setDialogParamsAsBottomSheet() { 167 final Window window = mDialog.getWindow(); 168 window.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY); 169 window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM 170 | WindowManager.LayoutParams.FLAG_DIM_BEHIND); 171 window.setDimAmount(0.6f); 172 window.addPrivateFlags(WindowManager.LayoutParams.SYSTEM_FLAG_SHOW_FOR_ALL_USERS); 173 window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN); 174 window.setGravity(Gravity.BOTTOM | Gravity.CENTER); 175 window.setCloseOnTouchOutside(true); 176 final WindowManager.LayoutParams params = window.getAttributes(); 177 params.width = WindowManager.LayoutParams.MATCH_PARENT; 178 params.accessibilityTitle = 179 mContext.getString(R.string.autofill_picker_accessibility_title); 180 params.windowAnimations = R.style.AutofillSaveAnimation; 181 } 182 setServiceIcon(View decor, Drawable serviceIcon)183 private void setServiceIcon(View decor, Drawable serviceIcon) { 184 if (serviceIcon == null) { 185 return; 186 } 187 188 final ImageView iconView = decor.findViewById(R.id.autofill_service_icon); 189 final int actualWidth = serviceIcon.getMinimumWidth(); 190 final int actualHeight = serviceIcon.getMinimumHeight(); 191 if (sDebug) { 192 Slog.d(TAG, "Adding service icon " 193 + "(" + actualWidth + "x" + actualHeight + ")"); 194 } 195 iconView.setImageDrawable(serviceIcon); 196 iconView.setVisibility(View.VISIBLE); 197 } 198 setHeader(View decor, FillResponse response)199 private void setHeader(View decor, FillResponse response) { 200 final RemoteViews presentation = response.getDialogHeader(); 201 if (presentation == null) { 202 return; 203 } 204 205 final ViewGroup container = decor.findViewById(R.id.autofill_dialog_header); 206 final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> { 207 if (pendingIntent != null) { 208 mCallback.startIntentSender(pendingIntent.getIntentSender()); 209 } 210 return true; 211 }; 212 213 final View content = presentation.applyWithTheme( 214 mContext, (ViewGroup) decor, interceptionHandler, mThemeId); 215 container.addView(content); 216 container.setVisibility(View.VISIBLE); 217 } 218 setDismissButton(View decor)219 private void setDismissButton(View decor) { 220 final TextView noButton = decor.findViewById(R.id.autofill_dialog_no); 221 // set "No thinks" by default 222 noButton.setText(R.string.autofill_save_no); 223 noButton.setOnClickListener((v) -> mCallback.onDismissed()); 224 } 225 setContinueButton(View decor, View.OnClickListener listener)226 private void setContinueButton(View decor, View.OnClickListener listener) { 227 final TextView yesButton = decor.findViewById(R.id.autofill_dialog_yes); 228 // set "Continue" by default 229 yesButton.setText(R.string.autofill_continue_yes); 230 yesButton.setOnClickListener(listener); 231 yesButton.setVisibility(View.VISIBLE); 232 } 233 initialAuthenticationLayout(View decor, FillResponse response)234 private void initialAuthenticationLayout(View decor, FillResponse response) { 235 RemoteViews presentation = response.getDialogPresentation(); 236 if (presentation == null) { 237 presentation = response.getPresentation(); 238 } 239 if (presentation == null) { 240 throw new RuntimeException("No presentation for fill dialog authentication"); 241 } 242 243 // insert authentication item under autofill_dialog_container 244 final ViewGroup container = decor.findViewById(R.id.autofill_dialog_container); 245 final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> { 246 if (pendingIntent != null) { 247 mCallback.startIntentSender(pendingIntent.getIntentSender()); 248 } 249 return true; 250 }; 251 final View content = presentation.applyWithTheme( 252 mContext, (ViewGroup) decor, interceptionHandler, mThemeId); 253 container.addView(content); 254 container.setVisibility(View.VISIBLE); 255 container.setFocusable(true); 256 container.setOnClickListener(v -> mCallback.onResponsePicked(response)); 257 // just single item, set up continue button 258 setContinueButton(decor, v -> mCallback.onResponsePicked(response)); 259 } 260 createDatasetItems(FillResponse response, AutofillId focusedViewId)261 private ArrayList<ViewItem> createDatasetItems(FillResponse response, 262 AutofillId focusedViewId) { 263 final int datasetCount = response.getDatasets().size(); 264 if (sVerbose) { 265 Slog.v(TAG, "Number datasets: " + datasetCount + " max visible: " 266 + mVisibleDatasetsMaxCount); 267 } 268 269 final RemoteViews.InteractionHandler interceptionHandler = (view, pendingIntent, r) -> { 270 if (pendingIntent != null) { 271 mCallback.startIntentSender(pendingIntent.getIntentSender()); 272 } 273 return true; 274 }; 275 276 final ArrayList<ViewItem> items = new ArrayList<>(datasetCount); 277 for (int i = 0; i < datasetCount; i++) { 278 final Dataset dataset = response.getDatasets().get(i); 279 final int index = dataset.getFieldIds().indexOf(focusedViewId); 280 if (index >= 0) { 281 RemoteViews presentation = dataset.getFieldDialogPresentation(index); 282 if (presentation == null) { 283 if (sDebug) { 284 Slog.w(TAG, "not displaying UI on field " + focusedViewId + " because " 285 + "service didn't provide a presentation for it on " + dataset); 286 } 287 continue; 288 } 289 final View view; 290 try { 291 if (sVerbose) Slog.v(TAG, "setting remote view for " + focusedViewId); 292 view = presentation.applyWithTheme( 293 mContext, null, interceptionHandler, mThemeId); 294 } catch (RuntimeException e) { 295 Slog.e(TAG, "Error inflating remote views", e); 296 continue; 297 } 298 // TODO: Extract the shared filtering logic here and in FillUi to a common 299 // method. 300 final Dataset.DatasetFieldFilter filter = dataset.getFilter(index); 301 Pattern filterPattern = null; 302 String valueText = null; 303 boolean filterable = true; 304 if (filter == null) { 305 final AutofillValue value = dataset.getFieldValues().get(index); 306 if (value != null && value.isText()) { 307 valueText = value.getTextValue().toString().toLowerCase(); 308 } 309 } else { 310 filterPattern = filter.pattern; 311 if (filterPattern == null) { 312 if (sVerbose) { 313 Slog.v(TAG, "Explicitly disabling filter at id " + focusedViewId 314 + " for dataset #" + index); 315 } 316 filterable = false; 317 } 318 } 319 320 items.add(new ViewItem(dataset, filterPattern, filterable, valueText, view)); 321 } 322 } 323 return items; 324 } 325 initialDatasetLayout(View decor, String filterText)326 private void initialDatasetLayout(View decor, String filterText) { 327 final AdapterView.OnItemClickListener onItemClickListener = 328 (adapter, view, position, id) -> { 329 final ViewItem vi = mAdapter.getItem(position); 330 mCallback.onDatasetPicked(vi.dataset); 331 }; 332 333 mListView.setAdapter(mAdapter); 334 mListView.setVisibility(View.VISIBLE); 335 mListView.setOnItemClickListener(onItemClickListener); 336 337 if (mAdapter.getCount() == 1) { 338 // just single item, set up continue button 339 setContinueButton(decor, (v) -> 340 onItemClickListener.onItemClick(null, null, 0, 0)); 341 } 342 343 if (filterText == null) { 344 mFilterText = null; 345 } else { 346 mFilterText = filterText.toLowerCase(); 347 } 348 349 final int oldCount = mAdapter.getCount(); 350 mAdapter.getFilter().filter(mFilterText, (count) -> { 351 if (mDestroyed) { 352 return; 353 } 354 if (count <= 0) { 355 if (sDebug) { 356 final int size = mFilterText == null ? 0 : mFilterText.length(); 357 Slog.d(TAG, "No dataset matches filter with " + size + " chars"); 358 } 359 mCallback.onCanceled(); 360 } else { 361 362 if (mAdapter.getCount() > mVisibleDatasetsMaxCount) { 363 mListView.setVerticalScrollBarEnabled(true); 364 mListView.onVisibilityAggregated(true); 365 } else { 366 mListView.setVerticalScrollBarEnabled(false); 367 } 368 if (mAdapter.getCount() != oldCount) { 369 mListView.requestLayout(); 370 } 371 } 372 }); 373 } 374 show()375 private void show() { 376 Slog.i(TAG, "Showing fill dialog"); 377 mDialog.show(); 378 mOverlayControl.hideOverlays(); 379 } 380 isShowing()381 boolean isShowing() { 382 return mDialog.isShowing(); 383 } 384 hide()385 void hide() { 386 if (sVerbose) Slog.v(TAG, "Hiding fill dialog."); 387 try { 388 mDialog.hide(); 389 } finally { 390 mOverlayControl.showOverlays(); 391 } 392 } 393 destroy()394 void destroy() { 395 try { 396 if (sDebug) Slog.d(TAG, "destroy()"); 397 throwIfDestroyed(); 398 399 mDialog.dismiss(); 400 mDestroyed = true; 401 } finally { 402 mOverlayControl.showOverlays(); 403 } 404 } 405 throwIfDestroyed()406 private void throwIfDestroyed() { 407 if (mDestroyed) { 408 throw new IllegalStateException("cannot interact with a destroyed instance"); 409 } 410 } 411 412 @Override toString()413 public String toString() { 414 // TODO toString 415 return "NO TITLE"; 416 } 417 dump(PrintWriter pw, String prefix)418 void dump(PrintWriter pw, String prefix) { 419 420 pw.print(prefix); pw.print("service: "); pw.println(mServicePackageName); 421 pw.print(prefix); pw.print("app: "); pw.println(mComponentName.toShortString()); 422 pw.print(prefix); pw.print("theme id: "); pw.print(mThemeId); 423 switch (mThemeId) { 424 case THEME_ID_DARK: 425 pw.println(" (dark)"); 426 break; 427 case THEME_ID_LIGHT: 428 pw.println(" (light)"); 429 break; 430 default: 431 pw.println("(UNKNOWN_MODE)"); 432 break; 433 } 434 final View view = mDialog.getWindow().getDecorView(); 435 final int[] loc = view.getLocationOnScreen(); 436 pw.print(prefix); pw.print("coordinates: "); 437 pw.print('('); pw.print(loc[0]); pw.print(','); pw.print(loc[1]); pw.print(')'); 438 pw.print('('); 439 pw.print(loc[0] + view.getWidth()); pw.print(','); 440 pw.print(loc[1] + view.getHeight()); pw.println(')'); 441 pw.print(prefix); pw.print("destroyed: "); pw.println(mDestroyed); 442 } 443 announceSearchResultIfNeeded()444 private void announceSearchResultIfNeeded() { 445 if (AccessibilityManager.getInstance(mContext).isEnabled()) { 446 if (mAnnounceFilterResult == null) { 447 mAnnounceFilterResult = new AnnounceFilterResult(); 448 } 449 mAnnounceFilterResult.post(); 450 } 451 } 452 453 // TODO: Below code copied from FullUi, Extract the shared filtering logic here 454 // and in FillUi to a common method. 455 private final class AnnounceFilterResult implements Runnable { 456 private static final int SEARCH_RESULT_ANNOUNCEMENT_DELAY = 1000; // 1 sec 457 post()458 public void post() { 459 remove(); 460 mListView.postDelayed(this, SEARCH_RESULT_ANNOUNCEMENT_DELAY); 461 } 462 remove()463 public void remove() { 464 mListView.removeCallbacks(this); 465 } 466 467 @Override run()468 public void run() { 469 final int count = mListView.getAdapter().getCount(); 470 final String text; 471 if (count <= 0) { 472 text = mContext.getString(R.string.autofill_picker_no_suggestions); 473 } else { 474 Map<String, Object> arguments = new HashMap<>(); 475 arguments.put("count", count); 476 text = PluralsMessageFormatter.format(mContext.getResources(), 477 arguments, 478 R.string.autofill_picker_some_suggestions); 479 } 480 mListView.announceForAccessibility(text); 481 } 482 } 483 484 private final class ItemsAdapter extends BaseAdapter implements Filterable { 485 private @NonNull final List<ViewItem> mAllItems; 486 487 private @NonNull final List<ViewItem> mFilteredItems = new ArrayList<>(); 488 ItemsAdapter(@onNull List<ViewItem> items)489 ItemsAdapter(@NonNull List<ViewItem> items) { 490 mAllItems = Collections.unmodifiableList(new ArrayList<>(items)); 491 mFilteredItems.addAll(items); 492 } 493 494 @Override getFilter()495 public Filter getFilter() { 496 return new Filter() { 497 @Override 498 protected FilterResults performFiltering(CharSequence filterText) { 499 // No locking needed as mAllItems is final an immutable 500 final List<ViewItem> filtered = mAllItems.stream() 501 .filter((item) -> item.matches(filterText)) 502 .collect(Collectors.toList()); 503 final FilterResults results = new FilterResults(); 504 results.values = filtered; 505 results.count = filtered.size(); 506 return results; 507 } 508 509 @Override 510 protected void publishResults(CharSequence constraint, FilterResults results) { 511 final boolean resultCountChanged; 512 final int oldItemCount = mFilteredItems.size(); 513 mFilteredItems.clear(); 514 if (results.count > 0) { 515 @SuppressWarnings("unchecked") final List<ViewItem> items = 516 (List<ViewItem>) results.values; 517 mFilteredItems.addAll(items); 518 } 519 resultCountChanged = (oldItemCount != mFilteredItems.size()); 520 if (resultCountChanged) { 521 announceSearchResultIfNeeded(); 522 } 523 notifyDataSetChanged(); 524 } 525 }; 526 } 527 528 @Override getCount()529 public int getCount() { 530 return mFilteredItems.size(); 531 } 532 533 @Override getItem(int position)534 public ViewItem getItem(int position) { 535 return mFilteredItems.get(position); 536 } 537 538 @Override getItemId(int position)539 public long getItemId(int position) { 540 return position; 541 } 542 543 @Override getView(int position, View convertView, ViewGroup parent)544 public View getView(int position, View convertView, ViewGroup parent) { 545 return getItem(position).view; 546 } 547 548 @Override toString()549 public String toString() { 550 return "ItemsAdapter: [all=" + mAllItems + ", filtered=" + mFilteredItems + "]"; 551 } 552 } 553 554 555 /** 556 * An item for the list view - either a (clickable) dataset or a (read-only) header / footer. 557 */ 558 private static class ViewItem { 559 public final @Nullable String value; 560 public final @Nullable Dataset dataset; 561 public final @NonNull View view; 562 public final @Nullable Pattern filter; 563 public final boolean filterable; 564 565 /** 566 * Default constructor. 567 * 568 * @param dataset dataset associated with the item 569 * @param filter optional filter set by the service to determine how the item should be 570 * filtered 571 * @param filterable optional flag set by the service to indicate this item should not be 572 * filtered (typically used when the dataset has value but it's sensitive, like a password) 573 * @param value dataset value 574 * @param view dataset presentation. 575 */ 576 ViewItem(@NonNull Dataset dataset, @Nullable Pattern filter, boolean filterable, 577 @Nullable String value, @NonNull View view) { 578 this.dataset = dataset; 579 this.value = value; 580 this.view = view; 581 this.filter = filter; 582 this.filterable = filterable; 583 } 584 585 /** 586 * Returns whether this item matches the value input by the user so it can be included 587 * in the filtered datasets. 588 */ 589 public boolean matches(CharSequence filterText) { 590 if (TextUtils.isEmpty(filterText)) { 591 // Always show item when the user input is empty 592 return true; 593 } 594 if (!filterable) { 595 // Service explicitly disabled filtering using a null Pattern. 596 return false; 597 } 598 final String constraintLowerCase = filterText.toString().toLowerCase(); 599 if (filter != null) { 600 // Uses pattern provided by service 601 return filter.matcher(constraintLowerCase).matches(); 602 } else { 603 // Compares it with dataset value with dataset 604 return (value == null) 605 ? (dataset.getAuthentication() == null) 606 : value.toLowerCase().startsWith(constraintLowerCase); 607 } 608 } 609 610 @Override 611 public String toString() { 612 final StringBuilder builder = new StringBuilder("ViewItem:[view=") 613 .append(view.getAutofillId()); 614 final String datasetId = dataset == null ? null : dataset.getId(); 615 if (datasetId != null) { 616 builder.append(", dataset=").append(datasetId); 617 } 618 if (value != null) { 619 // Cannot print value because it could contain PII 620 builder.append(", value=").append(value.length()).append("_chars"); 621 } 622 if (filterable) { 623 builder.append(", filterable"); 624 } 625 if (filter != null) { 626 // Filter should not have PII, but it could be a huge regexp 627 builder.append(", filter=").append(filter.pattern().length()).append("_chars"); 628 } 629 return builder.append(']').toString(); 630 } 631 } 632 } 633