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