1 /* 2 * Copyright (C) 2017 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.app.PendingIntent; 26 import android.content.ComponentName; 27 import android.content.Context; 28 import android.content.Intent; 29 import android.content.IntentSender; 30 import android.content.res.Resources; 31 import android.graphics.drawable.Drawable; 32 import android.metrics.LogMaker; 33 import android.os.Handler; 34 import android.os.IBinder; 35 import android.os.RemoteException; 36 import android.service.autofill.BatchUpdates; 37 import android.service.autofill.CustomDescription; 38 import android.service.autofill.InternalTransformation; 39 import android.service.autofill.InternalValidator; 40 import android.service.autofill.SaveInfo; 41 import android.service.autofill.ValueFinder; 42 import android.text.Html; 43 import android.util.ArraySet; 44 import android.util.Pair; 45 import android.util.Slog; 46 import android.view.ContextThemeWrapper; 47 import android.view.Gravity; 48 import android.view.LayoutInflater; 49 import android.view.View; 50 import android.view.ViewGroup; 51 import android.view.ViewGroup.LayoutParams; 52 import android.view.Window; 53 import android.view.WindowManager; 54 import android.view.autofill.AutofillManager; 55 import android.widget.ImageView; 56 import android.widget.RemoteViews; 57 import android.widget.TextView; 58 59 import com.android.internal.R; 60 import com.android.internal.logging.MetricsLogger; 61 import com.android.internal.logging.nano.MetricsProto.MetricsEvent; 62 import com.android.server.UiThread; 63 import com.android.server.autofill.Helper; 64 65 import java.io.PrintWriter; 66 import java.util.ArrayList; 67 68 /** 69 * Autofill Save Prompt 70 */ 71 final class SaveUi { 72 73 private static final String TAG = "AutofillSaveUi"; 74 75 private static final int THEME_ID = 76 com.android.internal.R.style.Theme_DeviceDefault_Autofill_Save; 77 78 public interface OnSaveListener { 79 void onSave(); 80 void onCancel(IntentSender listener); 81 void onDestroy(); 82 } 83 84 private class OneTimeListener implements OnSaveListener { 85 86 private final OnSaveListener mRealListener; 87 private boolean mDone; 88 89 OneTimeListener(OnSaveListener realListener) { 90 mRealListener = realListener; 91 } 92 93 @Override 94 public void onSave() { 95 if (sDebug) Slog.d(TAG, "OneTimeListener.onSave(): " + mDone); 96 if (mDone) { 97 return; 98 } 99 mDone = true; 100 mRealListener.onSave(); 101 } 102 103 @Override 104 public void onCancel(IntentSender listener) { 105 if (sDebug) Slog.d(TAG, "OneTimeListener.onCancel(): " + mDone); 106 if (mDone) { 107 return; 108 } 109 mDone = true; 110 mRealListener.onCancel(listener); 111 } 112 113 @Override 114 public void onDestroy() { 115 if (sDebug) Slog.d(TAG, "OneTimeListener.onDestroy(): " + mDone); 116 if (mDone) { 117 return; 118 } 119 mDone = true; 120 mRealListener.onDestroy(); 121 } 122 } 123 124 private final Handler mHandler = UiThread.getHandler(); 125 private final MetricsLogger mMetricsLogger = new MetricsLogger(); 126 127 private final @NonNull Dialog mDialog; 128 129 private final @NonNull OneTimeListener mListener; 130 131 private final @NonNull OverlayControl mOverlayControl; 132 133 private final CharSequence mTitle; 134 private final CharSequence mSubTitle; 135 private final PendingUi mPendingUi; 136 private final String mServicePackageName; 137 private final ComponentName mComponentName; 138 private final boolean mCompatMode; 139 140 private boolean mDestroyed; 141 142 SaveUi(@NonNull Context context, @NonNull PendingUi pendingUi, 143 @NonNull CharSequence serviceLabel, @NonNull Drawable serviceIcon, 144 @Nullable String servicePackageName, @NonNull ComponentName componentName, 145 @NonNull SaveInfo info, @NonNull ValueFinder valueFinder, 146 @NonNull OverlayControl overlayControl, @NonNull OnSaveListener listener, 147 boolean compatMode) { 148 mPendingUi= pendingUi; 149 mListener = new OneTimeListener(listener); 150 mOverlayControl = overlayControl; 151 mServicePackageName = servicePackageName; 152 mComponentName = componentName; 153 mCompatMode = compatMode; 154 155 context = new ContextThemeWrapper(context, THEME_ID); 156 final LayoutInflater inflater = LayoutInflater.from(context); 157 final View view = inflater.inflate(R.layout.autofill_save, null); 158 159 final TextView titleView = view.findViewById(R.id.autofill_save_title); 160 161 final ArraySet<String> types = new ArraySet<>(3); 162 final int type = info.getType(); 163 164 if ((type & SaveInfo.SAVE_DATA_TYPE_PASSWORD) != 0) { 165 types.add(context.getString(R.string.autofill_save_type_password)); 166 } 167 if ((type & SaveInfo.SAVE_DATA_TYPE_ADDRESS) != 0) { 168 types.add(context.getString(R.string.autofill_save_type_address)); 169 } 170 if ((type & SaveInfo.SAVE_DATA_TYPE_CREDIT_CARD) != 0) { 171 types.add(context.getString(R.string.autofill_save_type_credit_card)); 172 } 173 if ((type & SaveInfo.SAVE_DATA_TYPE_USERNAME) != 0) { 174 types.add(context.getString(R.string.autofill_save_type_username)); 175 } 176 if ((type & SaveInfo.SAVE_DATA_TYPE_EMAIL_ADDRESS) != 0) { 177 types.add(context.getString(R.string.autofill_save_type_email_address)); 178 } 179 180 switch (types.size()) { 181 case 1: 182 mTitle = Html.fromHtml(context.getString(R.string.autofill_save_title_with_type, 183 types.valueAt(0), serviceLabel), 0); 184 break; 185 case 2: 186 mTitle = Html.fromHtml(context.getString(R.string.autofill_save_title_with_2types, 187 types.valueAt(0), types.valueAt(1), serviceLabel), 0); 188 break; 189 case 3: 190 mTitle = Html.fromHtml(context.getString(R.string.autofill_save_title_with_3types, 191 types.valueAt(0), types.valueAt(1), types.valueAt(2), serviceLabel), 0); 192 break; 193 default: 194 // Use generic if more than 3 or invalid type (size 0). 195 mTitle = Html.fromHtml( 196 context.getString(R.string.autofill_save_title, serviceLabel), 0); 197 } 198 titleView.setText(mTitle); 199 200 setServiceIcon(context, view, serviceIcon); 201 202 final boolean hasCustomDescription = 203 applyCustomDescription(context, view, valueFinder, info); 204 if (hasCustomDescription) { 205 mSubTitle = null; 206 if (sDebug) Slog.d(TAG, "on constructor: applied custom description"); 207 } else { 208 mSubTitle = info.getDescription(); 209 if (mSubTitle != null) { 210 writeLog(MetricsEvent.AUTOFILL_SAVE_CUSTOM_SUBTITLE, type); 211 final ViewGroup subtitleContainer = 212 view.findViewById(R.id.autofill_save_custom_subtitle); 213 final TextView subtitleView = new TextView(context); 214 subtitleView.setText(mSubTitle); 215 subtitleContainer.addView(subtitleView, 216 new LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 217 ViewGroup.LayoutParams.WRAP_CONTENT)); 218 subtitleContainer.setVisibility(View.VISIBLE); 219 } 220 if (sDebug) Slog.d(TAG, "on constructor: title=" + mTitle + ", subTitle=" + mSubTitle); 221 } 222 223 final TextView noButton = view.findViewById(R.id.autofill_save_no); 224 if (info.getNegativeActionStyle() == SaveInfo.NEGATIVE_BUTTON_STYLE_REJECT) { 225 noButton.setText(R.string.save_password_notnow); 226 } else { 227 noButton.setText(R.string.autofill_save_no); 228 } 229 noButton.setOnClickListener((v) -> mListener.onCancel(info.getNegativeActionListener())); 230 231 final View yesButton = view.findViewById(R.id.autofill_save_yes); 232 yesButton.setOnClickListener((v) -> mListener.onSave()); 233 234 mDialog = new Dialog(context, THEME_ID); 235 mDialog.setContentView(view); 236 237 // Dialog can be dismissed when touched outside, but the negative listener should not be 238 // notified (hence the null argument). 239 mDialog.setOnDismissListener((d) -> mListener.onCancel(null)); 240 241 final Window window = mDialog.getWindow(); 242 window.setType(WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY); 243 window.addFlags(WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM 244 | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL 245 | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH); 246 window.addPrivateFlags(WindowManager.LayoutParams.PRIVATE_FLAG_SHOW_FOR_ALL_USERS); 247 window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN); 248 window.setGravity(Gravity.BOTTOM | Gravity.CENTER); 249 window.setCloseOnTouchOutside(true); 250 final WindowManager.LayoutParams params = window.getAttributes(); 251 params.width = WindowManager.LayoutParams.MATCH_PARENT; 252 params.accessibilityTitle = context.getString(R.string.autofill_save_accessibility_title); 253 params.windowAnimations = R.style.AutofillSaveAnimation; 254 255 show(); 256 } 257 258 private boolean applyCustomDescription(@NonNull Context context, @NonNull View saveUiView, 259 @NonNull ValueFinder valueFinder, @NonNull SaveInfo info) { 260 final CustomDescription customDescription = info.getCustomDescription(); 261 if (customDescription == null) { 262 return false; 263 } 264 final int type = info.getType(); 265 writeLog(MetricsEvent.AUTOFILL_SAVE_CUSTOM_DESCRIPTION, type); 266 267 final RemoteViews template = customDescription.getPresentation(); 268 if (template == null) { 269 Slog.w(TAG, "No remote view on custom description"); 270 return false; 271 } 272 273 // First apply the unconditional transformations (if any) to the templates. 274 final ArrayList<Pair<Integer, InternalTransformation>> transformations = 275 customDescription.getTransformations(); 276 if (transformations != null) { 277 if (!InternalTransformation.batchApply(valueFinder, template, transformations)) { 278 Slog.w(TAG, "could not apply main transformations on custom description"); 279 return false; 280 } 281 } 282 283 final RemoteViews.OnClickHandler handler = new RemoteViews.OnClickHandler() { 284 @Override 285 public boolean onClickHandler(View view, PendingIntent pendingIntent, 286 Intent intent) { 287 final LogMaker log = 288 newLogMaker(MetricsEvent.AUTOFILL_SAVE_LINK_TAPPED, type); 289 // We need to hide the Save UI before launching the pending intent, and 290 // restore back it once the activity is finished, and that's achieved by 291 // adding a custom extra in the activity intent. 292 final boolean isValid = isValidLink(pendingIntent, intent); 293 if (!isValid) { 294 log.setType(MetricsEvent.TYPE_UNKNOWN); 295 mMetricsLogger.write(log); 296 return false; 297 } 298 if (sVerbose) Slog.v(TAG, "Intercepting custom description intent"); 299 final IBinder token = mPendingUi.getToken(); 300 intent.putExtra(AutofillManager.EXTRA_RESTORE_SESSION_TOKEN, token); 301 try { 302 mPendingUi.client.startIntentSender(pendingIntent.getIntentSender(), 303 intent); 304 mPendingUi.setState(PendingUi.STATE_PENDING); 305 if (sDebug) Slog.d(TAG, "hiding UI until restored with token " + token); 306 hide(); 307 log.setType(MetricsEvent.TYPE_OPEN); 308 mMetricsLogger.write(log); 309 return true; 310 } catch (RemoteException e) { 311 Slog.w(TAG, "error triggering pending intent: " + intent); 312 log.setType(MetricsEvent.TYPE_FAILURE); 313 mMetricsLogger.write(log); 314 return false; 315 } 316 } 317 }; 318 319 try { 320 // Create the remote view peer. 321 template.setApplyTheme(THEME_ID); 322 final View customSubtitleView = template.apply(context, null, handler); 323 324 // And apply batch updates (if any). 325 final ArrayList<Pair<InternalValidator, BatchUpdates>> updates = 326 customDescription.getUpdates(); 327 if (updates != null) { 328 final int size = updates.size(); 329 if (sDebug) Slog.d(TAG, "custom description has " + size + " batch updates"); 330 for (int i = 0; i < size; i++) { 331 final Pair<InternalValidator, BatchUpdates> pair = updates.get(i); 332 final InternalValidator condition = pair.first; 333 if (condition == null || !condition.isValid(valueFinder)) { 334 if (sDebug) Slog.d(TAG, "Skipping batch update #" + i ); 335 continue; 336 } 337 final BatchUpdates batchUpdates = pair.second; 338 // First apply the updates... 339 final RemoteViews templateUpdates = batchUpdates.getUpdates(); 340 if (templateUpdates != null) { 341 if (sDebug) Slog.d(TAG, "Applying template updates for batch update #" + i); 342 templateUpdates.reapply(context, customSubtitleView); 343 } 344 // Then the transformations... 345 final ArrayList<Pair<Integer, InternalTransformation>> batchTransformations = 346 batchUpdates.getTransformations(); 347 if (batchTransformations != null) { 348 if (sDebug) { 349 Slog.d(TAG, "Applying child transformation for batch update #" + i 350 + ": " + batchTransformations); 351 } 352 if (!InternalTransformation.batchApply(valueFinder, template, 353 batchTransformations)) { 354 Slog.w(TAG, "Could not apply child transformation for batch update " 355 + "#" + i + ": " + batchTransformations); 356 return false; 357 } 358 template.reapply(context, customSubtitleView); 359 } 360 } 361 } 362 363 // Finally, add the custom description to the save UI. 364 final ViewGroup subtitleContainer = 365 saveUiView.findViewById(R.id.autofill_save_custom_subtitle); 366 subtitleContainer.addView(customSubtitleView); 367 subtitleContainer.setVisibility(View.VISIBLE); 368 return true; 369 } catch (Exception e) { 370 Slog.e(TAG, "Error applying custom description. ", e); 371 } 372 return false; 373 } 374 375 private void setServiceIcon(Context context, View view, Drawable serviceIcon) { 376 final ImageView iconView = view.findViewById(R.id.autofill_save_icon); 377 final Resources res = context.getResources(); 378 379 final int maxWidth = res.getDimensionPixelSize(R.dimen.autofill_save_icon_max_size); 380 final int maxHeight = maxWidth; 381 final int actualWidth = serviceIcon.getMinimumWidth(); 382 final int actualHeight = serviceIcon.getMinimumHeight(); 383 384 if (actualWidth <= maxWidth && actualHeight <= maxHeight) { 385 if (sDebug) { 386 Slog.d(TAG, "Adding service icon " 387 + "(" + actualWidth + "x" + actualHeight + ") as it's less than maximum " 388 + "(" + maxWidth + "x" + maxHeight + ")."); 389 } 390 iconView.setImageDrawable(serviceIcon); 391 } else { 392 Slog.w(TAG, "Not adding service icon of size " 393 + "(" + actualWidth + "x" + actualHeight + ") because maximum is " 394 + "(" + maxWidth + "x" + maxHeight + ")."); 395 ((ViewGroup)iconView.getParent()).removeView(iconView); 396 } 397 } 398 399 private static boolean isValidLink(PendingIntent pendingIntent, Intent intent) { 400 if (pendingIntent == null) { 401 Slog.w(TAG, "isValidLink(): custom description without pending intent"); 402 return false; 403 } 404 if (!pendingIntent.isActivity()) { 405 Slog.w(TAG, "isValidLink(): pending intent not for activity"); 406 return false; 407 } 408 if (intent == null) { 409 Slog.w(TAG, "isValidLink(): no intent"); 410 return false; 411 } 412 return true; 413 } 414 415 private LogMaker newLogMaker(int category, int saveType) { 416 return newLogMaker(category).addTaggedData(MetricsEvent.FIELD_AUTOFILL_SAVE_TYPE, saveType); 417 } 418 419 private LogMaker newLogMaker(int category) { 420 return Helper.newLogMaker(category, mComponentName, mServicePackageName, 421 mPendingUi.sessionId, mCompatMode); 422 } 423 424 private void writeLog(int category, int saveType) { 425 mMetricsLogger.write(newLogMaker(category, saveType)); 426 } 427 428 /** 429 * Update the pending UI, if any. 430 * 431 * @param operation how to update it. 432 * @param token token associated with the pending UI - if it doesn't match the pending token, 433 * the operation will be ignored. 434 */ 435 void onPendingUi(int operation, @NonNull IBinder token) { 436 if (!mPendingUi.matches(token)) { 437 Slog.w(TAG, "restore(" + operation + "): got token " + token + " instead of " 438 + mPendingUi.getToken()); 439 return; 440 } 441 final LogMaker log = newLogMaker(MetricsEvent.AUTOFILL_PENDING_SAVE_UI_OPERATION); 442 try { 443 switch (operation) { 444 case AutofillManager.PENDING_UI_OPERATION_RESTORE: 445 if (sDebug) Slog.d(TAG, "Restoring save dialog for " + token); 446 log.setType(MetricsEvent.TYPE_OPEN); 447 show(); 448 break; 449 case AutofillManager.PENDING_UI_OPERATION_CANCEL: 450 log.setType(MetricsEvent.TYPE_DISMISS); 451 if (sDebug) Slog.d(TAG, "Cancelling pending save dialog for " + token); 452 hide(); 453 break; 454 default: 455 log.setType(MetricsEvent.TYPE_FAILURE); 456 Slog.w(TAG, "restore(): invalid operation " + operation); 457 } 458 } finally { 459 mMetricsLogger.write(log); 460 } 461 mPendingUi.setState(PendingUi.STATE_FINISHED); 462 } 463 464 private void show() { 465 Slog.i(TAG, "Showing save dialog: " + mTitle); 466 mDialog.show(); 467 mOverlayControl.hideOverlays(); 468 } 469 470 PendingUi hide() { 471 if (sVerbose) Slog.v(TAG, "Hiding save dialog."); 472 try { 473 mDialog.hide(); 474 } finally { 475 mOverlayControl.showOverlays(); 476 } 477 return mPendingUi; 478 } 479 480 void destroy() { 481 try { 482 if (sDebug) Slog.d(TAG, "destroy()"); 483 throwIfDestroyed(); 484 mListener.onDestroy(); 485 mHandler.removeCallbacksAndMessages(mListener); 486 mDialog.dismiss(); 487 mDestroyed = true; 488 } finally { 489 mOverlayControl.showOverlays(); 490 } 491 } 492 493 private void throwIfDestroyed() { 494 if (mDestroyed) { 495 throw new IllegalStateException("cannot interact with a destroyed instance"); 496 } 497 } 498 499 @Override 500 public String toString() { 501 return mTitle == null ? "NO TITLE" : mTitle.toString(); 502 } 503 504 void dump(PrintWriter pw, String prefix) { 505 pw.print(prefix); pw.print("title: "); pw.println(mTitle); 506 pw.print(prefix); pw.print("subtitle: "); pw.println(mSubTitle); 507 pw.print(prefix); pw.print("pendingUi: "); pw.println(mPendingUi); 508 pw.print(prefix); pw.print("service: "); pw.println(mServicePackageName); 509 pw.print(prefix); pw.print("app: "); pw.println(mComponentName.toShortString()); 510 pw.print(prefix); pw.print("compat mode: "); pw.println(mCompatMode); 511 512 final View view = mDialog.getWindow().getDecorView(); 513 final int[] loc = view.getLocationOnScreen(); 514 pw.print(prefix); pw.print("coordinates: "); 515 pw.print('('); pw.print(loc[0]); pw.print(','); pw.print(loc[1]);pw.print(')'); 516 pw.print('('); 517 pw.print(loc[0] + view.getWidth()); pw.print(','); 518 pw.print(loc[1] + view.getHeight());pw.println(')'); 519 pw.print(prefix); pw.print("destroyed: "); pw.println(mDestroyed); 520 } 521 } 522