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