BugreportProgressService.java revision 0f2daaf2f7f0f9a35512e452231fd34e743ddc51
1/* 2 * Copyright (C) 2015 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 17package com.android.shell; 18 19import static android.os.Process.THREAD_PRIORITY_BACKGROUND; 20import static com.android.shell.BugreportPrefs.STATE_SHOW; 21import static com.android.shell.BugreportPrefs.getWarningState; 22 23import java.io.BufferedOutputStream; 24import java.io.ByteArrayInputStream; 25import java.io.File; 26import java.io.FileDescriptor; 27import java.io.FileInputStream; 28import java.io.FileOutputStream; 29import java.io.IOException; 30import java.io.InputStream; 31import java.io.PrintWriter; 32import java.nio.charset.StandardCharsets; 33import java.text.NumberFormat; 34import java.util.ArrayList; 35import java.util.Enumeration; 36import java.util.List; 37import java.util.zip.ZipEntry; 38import java.util.zip.ZipFile; 39import java.util.zip.ZipOutputStream; 40 41import libcore.io.Streams; 42 43import com.android.internal.annotations.VisibleForTesting; 44import com.android.internal.logging.MetricsLogger; 45import com.android.internal.logging.MetricsProto.MetricsEvent; 46import com.google.android.collect.Lists; 47 48import android.accounts.Account; 49import android.accounts.AccountManager; 50import android.annotation.SuppressLint; 51import android.app.AlertDialog; 52import android.app.Notification; 53import android.app.Notification.Action; 54import android.app.NotificationManager; 55import android.app.PendingIntent; 56import android.app.Service; 57import android.content.ClipData; 58import android.content.Context; 59import android.content.ContextWrapper; 60import android.content.DialogInterface; 61import android.content.Intent; 62import android.content.res.Configuration; 63import android.net.Uri; 64import android.os.AsyncTask; 65import android.os.Handler; 66import android.os.HandlerThread; 67import android.os.IBinder; 68import android.os.Looper; 69import android.os.Message; 70import android.os.Parcel; 71import android.os.Parcelable; 72import android.os.SystemProperties; 73import android.os.Vibrator; 74import android.support.v4.content.FileProvider; 75import android.text.TextUtils; 76import android.text.format.DateUtils; 77import android.util.Log; 78import android.util.Patterns; 79import android.util.SparseArray; 80import android.view.View; 81import android.view.WindowManager; 82import android.view.View.OnFocusChangeListener; 83import android.view.inputmethod.EditorInfo; 84import android.widget.Button; 85import android.widget.EditText; 86import android.widget.Toast; 87 88/** 89 * Service used to keep progress of bugreport processes ({@code dumpstate}). 90 * <p> 91 * The workflow is: 92 * <ol> 93 * <li>When {@code dumpstate} starts, it sends a {@code BUGREPORT_STARTED} with a sequential id, 94 * its pid, and the estimated total effort. 95 * <li>{@link BugreportReceiver} receives the intent and delegates it to this service. 96 * <li>Upon start, this service: 97 * <ol> 98 * <li>Issues a system notification so user can watch the progresss (which is 0% initially). 99 * <li>Polls the {@link SystemProperties} for updates on the {@code dumpstate} progress. 100 * <li>If the progress changed, it updates the system notification. 101 * </ol> 102 * <li>As {@code dumpstate} progresses, it updates the system property. 103 * <li>When {@code dumpstate} finishes, it sends a {@code BUGREPORT_FINISHED} intent. 104 * <li>{@link BugreportReceiver} receives the intent and delegates it to this service, which in 105 * turn: 106 * <ol> 107 * <li>Updates the system notification so user can share the bugreport. 108 * <li>Stops monitoring that {@code dumpstate} process. 109 * <li>Stops itself if it doesn't have any process left to monitor. 110 * </ol> 111 * </ol> 112 */ 113public class BugreportProgressService extends Service { 114 private static final String TAG = "BugreportProgressService"; 115 private static final boolean DEBUG = false; 116 117 private static final String AUTHORITY = "com.android.shell"; 118 119 // External intents sent by dumpstate. 120 static final String INTENT_BUGREPORT_STARTED = "android.intent.action.BUGREPORT_STARTED"; 121 static final String INTENT_BUGREPORT_FINISHED = "android.intent.action.BUGREPORT_FINISHED"; 122 static final String INTENT_REMOTE_BUGREPORT_FINISHED = 123 "android.intent.action.REMOTE_BUGREPORT_FINISHED"; 124 125 // Internal intents used on notification actions. 126 static final String INTENT_BUGREPORT_CANCEL = "android.intent.action.BUGREPORT_CANCEL"; 127 static final String INTENT_BUGREPORT_SHARE = "android.intent.action.BUGREPORT_SHARE"; 128 static final String INTENT_BUGREPORT_INFO_LAUNCH = 129 "android.intent.action.BUGREPORT_INFO_LAUNCH"; 130 static final String INTENT_BUGREPORT_SCREENSHOT = 131 "android.intent.action.BUGREPORT_SCREENSHOT"; 132 133 static final String EXTRA_BUGREPORT = "android.intent.extra.BUGREPORT"; 134 static final String EXTRA_SCREENSHOT = "android.intent.extra.SCREENSHOT"; 135 static final String EXTRA_ID = "android.intent.extra.ID"; 136 static final String EXTRA_PID = "android.intent.extra.PID"; 137 static final String EXTRA_MAX = "android.intent.extra.MAX"; 138 static final String EXTRA_NAME = "android.intent.extra.NAME"; 139 static final String EXTRA_TITLE = "android.intent.extra.TITLE"; 140 static final String EXTRA_DESCRIPTION = "android.intent.extra.DESCRIPTION"; 141 static final String EXTRA_ORIGINAL_INTENT = "android.intent.extra.ORIGINAL_INTENT"; 142 static final String EXTRA_INFO = "android.intent.extra.INFO"; 143 144 private static final int MSG_SERVICE_COMMAND = 1; 145 private static final int MSG_POLL = 2; 146 private static final int MSG_DELAYED_SCREENSHOT = 3; 147 private static final int MSG_SCREENSHOT_REQUEST = 4; 148 private static final int MSG_SCREENSHOT_RESPONSE = 5; 149 150 // Passed to Message.obtain() when msg.arg2 is not used. 151 private static final int UNUSED_ARG2 = -2; 152 153 /** 154 * Delay before a screenshot is taken. 155 * <p> 156 * Should be at least 3 seconds, otherwise its toast might show up in the screenshot. 157 */ 158 static final int SCREENSHOT_DELAY_SECONDS = 3; 159 160 /** Polling frequency, in milliseconds. */ 161 static final long POLLING_FREQUENCY = 2 * DateUtils.SECOND_IN_MILLIS; 162 163 /** How long (in ms) a dumpstate process will be monitored if it didn't show progress. */ 164 private static final long INACTIVITY_TIMEOUT = 10 * DateUtils.MINUTE_IN_MILLIS; 165 166 /** System properties used for monitoring progress. */ 167 private static final String DUMPSTATE_PREFIX = "dumpstate."; 168 private static final String PROGRESS_SUFFIX = ".progress"; 169 private static final String MAX_SUFFIX = ".max"; 170 private static final String NAME_SUFFIX = ".name"; 171 172 /** System property (and value) used to stop dumpstate. */ 173 // TODO: should call ActiveManager API instead 174 private static final String CTL_STOP = "ctl.stop"; 175 private static final String BUGREPORT_SERVICE = "bugreportplus"; 176 177 /** 178 * Directory on Shell's data storage where screenshots will be stored. 179 * <p> 180 * Must be a path supported by its FileProvider. 181 */ 182 private static final String SCREENSHOT_DIR = "bugreports"; 183 184 /** Managed dumpstate processes (keyed by id) */ 185 private final SparseArray<BugreportInfo> mProcesses = new SparseArray<>(); 186 187 private Context mContext; 188 private ServiceHandler mMainHandler; 189 private ScreenshotHandler mScreenshotHandler; 190 191 private final BugreportInfoDialog mInfoDialog = new BugreportInfoDialog(); 192 193 private File mScreenshotsDir; 194 195 /** 196 * Flag indicating whether a screenshot is being taken. 197 * <p> 198 * This is the only state that is shared between the 2 handlers and hence must have synchronized 199 * access. 200 */ 201 private boolean mTakingScreenshot; 202 203 @Override 204 public void onCreate() { 205 mContext = getApplicationContext(); 206 mMainHandler = new ServiceHandler("BugreportProgressServiceMainThread"); 207 mScreenshotHandler = new ScreenshotHandler("BugreportProgressServiceScreenshotThread"); 208 209 mScreenshotsDir = new File(new ContextWrapper(mContext).getFilesDir(), SCREENSHOT_DIR); 210 if (!mScreenshotsDir.exists()) { 211 Log.i(TAG, "Creating directory " + mScreenshotsDir + " to store temporary screenshots"); 212 if (!mScreenshotsDir.mkdir()) { 213 Log.w(TAG, "Could not create directory " + mScreenshotsDir); 214 } 215 } 216 } 217 218 @Override 219 public int onStartCommand(Intent intent, int flags, int startId) { 220 if (intent != null) { 221 // Handle it in a separate thread. 222 final Message msg = mMainHandler.obtainMessage(); 223 msg.what = MSG_SERVICE_COMMAND; 224 msg.obj = intent; 225 mMainHandler.sendMessage(msg); 226 } 227 228 // If service is killed it cannot be recreated because it would not know which 229 // dumpstate IDs it would have to watch. 230 return START_NOT_STICKY; 231 } 232 233 @Override 234 public IBinder onBind(Intent intent) { 235 return null; 236 } 237 238 @Override 239 public void onDestroy() { 240 mMainHandler.getLooper().quit(); 241 mScreenshotHandler.getLooper().quit(); 242 super.onDestroy(); 243 } 244 245 @Override 246 protected void dump(FileDescriptor fd, PrintWriter writer, String[] args) { 247 final int size = mProcesses.size(); 248 if (size == 0) { 249 writer.printf("No monitored processes"); 250 return; 251 } 252 writer.printf("Monitored dumpstate processes\n"); 253 writer.printf("-----------------------------\n"); 254 for (int i = 0; i < size; i++) { 255 writer.printf("%s\n", mProcesses.valueAt(i)); 256 } 257 } 258 259 /** 260 * Main thread used to handle all requests but taking screenshots. 261 */ 262 private final class ServiceHandler extends Handler { 263 public ServiceHandler(String name) { 264 super(newLooper(name)); 265 } 266 267 @Override 268 public void handleMessage(Message msg) { 269 if (msg.what == MSG_POLL) { 270 poll(); 271 return; 272 } 273 274 if (msg.what == MSG_DELAYED_SCREENSHOT) { 275 takeScreenshot(msg.arg1, msg.arg2); 276 return; 277 } 278 279 if (msg.what == MSG_SCREENSHOT_RESPONSE) { 280 handleScreenshotResponse(msg); 281 return; 282 } 283 284 if (msg.what != MSG_SERVICE_COMMAND) { 285 // Sanity check. 286 Log.e(TAG, "Invalid message type: " + msg.what); 287 return; 288 } 289 290 // At this point it's handling onStartCommand(), with the intent passed as an Extra. 291 if (!(msg.obj instanceof Intent)) { 292 // Sanity check. 293 Log.wtf(TAG, "handleMessage(): invalid msg.obj type: " + msg.obj); 294 return; 295 } 296 final Parcelable parcel = ((Intent) msg.obj).getParcelableExtra(EXTRA_ORIGINAL_INTENT); 297 final Intent intent; 298 if (parcel instanceof Intent) { 299 // The real intent was passed to BugreportReceiver, which delegated to the service. 300 intent = (Intent) parcel; 301 } else { 302 intent = (Intent) msg.obj; 303 } 304 final String action = intent.getAction(); 305 final int pid = intent.getIntExtra(EXTRA_PID, 0); 306 final int id = intent.getIntExtra(EXTRA_ID, 0); 307 final int max = intent.getIntExtra(EXTRA_MAX, -1); 308 final String name = intent.getStringExtra(EXTRA_NAME); 309 310 if (DEBUG) 311 Log.v(TAG, "action: " + action + ", name: " + name + ", id: " + id + ", pid: " 312 + pid + ", max: " + max); 313 switch (action) { 314 case INTENT_BUGREPORT_STARTED: 315 if (!startProgress(name, id, pid, max)) { 316 stopSelfWhenDone(); 317 return; 318 } 319 poll(); 320 break; 321 case INTENT_BUGREPORT_FINISHED: 322 if (id == 0) { 323 // Shouldn't happen, unless BUGREPORT_FINISHED is received from a legacy, 324 // out-of-sync dumpstate process. 325 Log.w(TAG, "Missing " + EXTRA_ID + " on intent " + intent); 326 } 327 onBugreportFinished(id, intent); 328 break; 329 case INTENT_BUGREPORT_INFO_LAUNCH: 330 launchBugreportInfoDialog(id); 331 break; 332 case INTENT_BUGREPORT_SCREENSHOT: 333 takeScreenshot(id, true); 334 break; 335 case INTENT_BUGREPORT_SHARE: 336 shareBugreport(id, (BugreportInfo) intent.getParcelableExtra(EXTRA_INFO)); 337 break; 338 case INTENT_BUGREPORT_CANCEL: 339 cancel(id); 340 break; 341 default: 342 Log.w(TAG, "Unsupported intent: " + action); 343 } 344 return; 345 346 } 347 348 private void poll() { 349 if (pollProgress()) { 350 // Keep polling... 351 sendEmptyMessageDelayed(MSG_POLL, POLLING_FREQUENCY); 352 } else { 353 Log.i(TAG, "Stopped polling"); 354 } 355 } 356 } 357 358 /** 359 * Separate thread used only to take screenshots so it doesn't block the main thread. 360 */ 361 private final class ScreenshotHandler extends Handler { 362 public ScreenshotHandler(String name) { 363 super(newLooper(name)); 364 } 365 366 @Override 367 public void handleMessage(Message msg) { 368 if (msg.what != MSG_SCREENSHOT_REQUEST) { 369 Log.e(TAG, "Invalid message type: " + msg.what); 370 return; 371 } 372 handleScreenshotRequest(msg); 373 } 374 } 375 376 private BugreportInfo getInfo(int id) { 377 final BugreportInfo info = mProcesses.get(id); 378 if (info == null) { 379 Log.w(TAG, "Not monitoring process with ID " + id); 380 } 381 return info; 382 } 383 384 /** 385 * Creates the {@link BugreportInfo} for a process and issue a system notification to 386 * indicate its progress. 387 * 388 * @return whether it succeeded or not. 389 */ 390 private boolean startProgress(String name, int id, int pid, int max) { 391 if (name == null) { 392 Log.w(TAG, "Missing " + EXTRA_NAME + " on start intent"); 393 } 394 if (id == -1) { 395 Log.e(TAG, "Missing " + EXTRA_ID + " on start intent"); 396 return false; 397 } 398 if (pid == -1) { 399 Log.e(TAG, "Missing " + EXTRA_PID + " on start intent"); 400 return false; 401 } 402 if (max <= 0) { 403 Log.e(TAG, "Invalid value for extra " + EXTRA_MAX + ": " + max); 404 return false; 405 } 406 407 final BugreportInfo info = new BugreportInfo(mContext, id, pid, name, max); 408 if (mProcesses.indexOfKey(id) >= 0) { 409 Log.w(TAG, "ID " + id + " already watched"); 410 } else { 411 mProcesses.put(info.id, info); 412 } 413 // Take initial screenshot. 414 takeScreenshot(id, false); 415 updateProgress(info); 416 return true; 417 } 418 419 /** 420 * Updates the system notification for a given bugreport. 421 */ 422 private void updateProgress(BugreportInfo info) { 423 if (info.max <= 0 || info.progress < 0) { 424 Log.e(TAG, "Invalid progress values for " + info); 425 return; 426 } 427 428 final NumberFormat nf = NumberFormat.getPercentInstance(); 429 nf.setMinimumFractionDigits(2); 430 nf.setMaximumFractionDigits(2); 431 final String percentText = nf.format((double) info.progress / info.max); 432 final Action cancelAction = new Action.Builder(null, mContext.getString( 433 com.android.internal.R.string.cancel), newCancelIntent(mContext, info)).build(); 434 final Intent infoIntent = new Intent(mContext, BugreportProgressService.class); 435 infoIntent.setAction(INTENT_BUGREPORT_INFO_LAUNCH); 436 infoIntent.putExtra(EXTRA_ID, info.id); 437 final PendingIntent infoPendingIntent = 438 PendingIntent.getService(mContext, info.id, infoIntent, 439 PendingIntent.FLAG_UPDATE_CURRENT); 440 final Action infoAction = new Action.Builder(null, 441 mContext.getString(R.string.bugreport_info_action), 442 infoPendingIntent).build(); 443 final Intent screenshotIntent = new Intent(mContext, BugreportProgressService.class); 444 screenshotIntent.setAction(INTENT_BUGREPORT_SCREENSHOT); 445 screenshotIntent.putExtra(EXTRA_ID, info.id); 446 PendingIntent screenshotPendingIntent = mTakingScreenshot ? null : PendingIntent 447 .getService(mContext, info.id, screenshotIntent, 448 PendingIntent.FLAG_UPDATE_CURRENT); 449 final Action screenshotAction = new Action.Builder(null, 450 mContext.getString(R.string.bugreport_screenshot_action), 451 screenshotPendingIntent).build(); 452 453 final String title = mContext.getString(R.string.bugreport_in_progress_title, info.id); 454 455 final String name = 456 info.name != null ? info.name : mContext.getString(R.string.bugreport_unnamed); 457 458 final Notification notification = new Notification.Builder(mContext) 459 .setSmallIcon(com.android.internal.R.drawable.stat_sys_adb) 460 .setContentTitle(title) 461 .setTicker(title) 462 .setContentText(name) 463 .setContentInfo(percentText) 464 .setProgress(info.max, info.progress, false) 465 .setOngoing(true) 466 .setLocalOnly(true) 467 .setColor(mContext.getColor( 468 com.android.internal.R.color.system_notification_accent_color)) 469 .setContentIntent(infoPendingIntent) 470 .addAction(infoAction) 471 .addAction(screenshotAction) 472 .addAction(cancelAction) 473 .build(); 474 475 if (info.finished) { 476 Log.w(TAG, "Not sending progress notification because bugreport has finished already (" 477 + info + ")"); 478 return; 479 } 480 if (DEBUG) { 481 Log.d(TAG, "Sending 'Progress' notification for id " + info.id + "(pid " + info.pid 482 + "): " + percentText); 483 } 484 NotificationManager.from(mContext).notify(TAG, info.id, notification); 485 } 486 487 /** 488 * Creates a {@link PendingIntent} for a notification action used to cancel a bugreport. 489 */ 490 private static PendingIntent newCancelIntent(Context context, BugreportInfo info) { 491 final Intent intent = new Intent(INTENT_BUGREPORT_CANCEL); 492 intent.setClass(context, BugreportProgressService.class); 493 intent.putExtra(EXTRA_ID, info.id); 494 return PendingIntent.getService(context, info.id, intent, 495 PendingIntent.FLAG_UPDATE_CURRENT); 496 } 497 498 /** 499 * Finalizes the progress on a given bugreport and cancel its notification. 500 */ 501 private void stopProgress(int id) { 502 if (mProcesses.indexOfKey(id) < 0) { 503 Log.w(TAG, "ID not watched: " + id); 504 } else { 505 Log.d(TAG, "Removing ID " + id); 506 mProcesses.remove(id); 507 } 508 Log.v(TAG, "stopProgress(" + id + "): cancel notification"); 509 NotificationManager.from(mContext).cancel(TAG, id); 510 stopSelfWhenDone(); 511 } 512 513 /** 514 * Cancels a bugreport upon user's request. 515 */ 516 private void cancel(int id) { 517 MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_CANCEL); 518 Log.v(TAG, "cancel: ID=" + id); 519 final BugreportInfo info = getInfo(id); 520 if (info != null && !info.finished) { 521 Log.i(TAG, "Cancelling bugreport service (ID=" + id + ") on user's request"); 522 setSystemProperty(CTL_STOP, BUGREPORT_SERVICE); 523 deleteScreenshots(info); 524 } 525 stopProgress(id); 526 } 527 528 /** 529 * Poll {@link SystemProperties} to get the progress on each monitored process. 530 * 531 * @return whether it should keep polling. 532 */ 533 private boolean pollProgress() { 534 final int total = mProcesses.size(); 535 if (total == 0) { 536 Log.d(TAG, "No process to poll progress."); 537 } 538 int activeProcesses = 0; 539 for (int i = 0; i < total; i++) { 540 final BugreportInfo info = mProcesses.valueAt(i); 541 if (info == null) { 542 Log.wtf(TAG, "pollProgress(): null info at index " + i + "(ID = " 543 + mProcesses.keyAt(i) + ")"); 544 continue; 545 } 546 547 final int pid = info.pid; 548 final int id = info.id; 549 if (info.finished) { 550 if (DEBUG) Log.v(TAG, "Skipping finished process " + pid + " (id: " + id + ")"); 551 continue; 552 } 553 activeProcesses++; 554 final String progressKey = DUMPSTATE_PREFIX + pid + PROGRESS_SUFFIX; 555 final int progress = SystemProperties.getInt(progressKey, 0); 556 if (progress == 0) { 557 Log.v(TAG, "System property " + progressKey + " is not set yet"); 558 } 559 final int max = SystemProperties.getInt(DUMPSTATE_PREFIX + pid + MAX_SUFFIX, 0); 560 final boolean maxChanged = max > 0 && max != info.max; 561 final boolean progressChanged = progress > 0 && progress != info.progress; 562 563 if (progressChanged || maxChanged) { 564 if (progressChanged) { 565 if (DEBUG) Log.v(TAG, "Updating progress for PID " + pid + "(id: " + id 566 + ") from " + info.progress + " to " + progress); 567 info.progress = progress; 568 } 569 if (maxChanged) { 570 Log.i(TAG, "Updating max progress for PID " + pid + "(id: " + id 571 + ") from " + info.max + " to " + max); 572 info.max = max; 573 } 574 info.lastUpdate = System.currentTimeMillis(); 575 updateProgress(info); 576 } else { 577 long inactiveTime = System.currentTimeMillis() - info.lastUpdate; 578 if (inactiveTime >= INACTIVITY_TIMEOUT) { 579 Log.w(TAG, "No progress update for PID " + pid + " since " 580 + info.getFormattedLastUpdate()); 581 stopProgress(info.id); 582 } 583 } 584 } 585 if (DEBUG) Log.v(TAG, "pollProgress() total=" + total + ", actives=" + activeProcesses); 586 return activeProcesses > 0; 587 } 588 589 /** 590 * Fetches a {@link BugreportInfo} for a given process and launches a dialog where the user can 591 * change its values. 592 */ 593 private void launchBugreportInfoDialog(int id) { 594 MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_DETAILS); 595 // Copy values so it doesn't lock mProcesses while UI is being updated 596 final String name, title, description; 597 final BugreportInfo info = getInfo(id); 598 if (info == null) { 599 // Most likely am killed Shell before user tapped the notification. Since system might 600 // be too busy anwyays, it's better to ignore the notification and switch back to the 601 // non-interactive mode (where the bugerport will be shared upon completion). 602 Log.w(TAG, "launchBugreportInfoDialog(): canceling notification because id " + id 603 + " was not found"); 604 // TODO: add test case to make sure notification is canceled. 605 NotificationManager.from(mContext).cancel(TAG, id); 606 return; 607 } 608 609 collapseNotificationBar(); 610 mInfoDialog.initialize(mContext, info); 611 } 612 613 /** 614 * Starting point for taking a screenshot. 615 * <p> 616 * If {@code delayed} is set, it first display a toast message and waits 617 * {@link #SCREENSHOT_DELAY_SECONDS} seconds before taking it, otherwise it takes the screenshot 618 * right away. 619 * <p> 620 * Typical usage is delaying when taken from the notification action, and taking it right away 621 * upon receiving a {@link #INTENT_BUGREPORT_STARTED}. 622 */ 623 private void takeScreenshot(int id, boolean delayed) { 624 MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_SCREENSHOT); 625 if (getInfo(id) == null) { 626 // Most likely am killed Shell before user tapped the notification. Since system might 627 // be too busy anwyays, it's better to ignore the notification and switch back to the 628 // non-interactive mode (where the bugerport will be shared upon completion). 629 Log.w(TAG, "takeScreenshot(): canceling notification because id " + id 630 + " was not found"); 631 // TODO: add test case to make sure notification is canceled. 632 NotificationManager.from(mContext).cancel(TAG, id); 633 return; 634 } 635 setTakingScreenshot(true); 636 if (delayed) { 637 collapseNotificationBar(); 638 final String msg = mContext.getResources() 639 .getQuantityString(com.android.internal.R.plurals.bugreport_countdown, 640 SCREENSHOT_DELAY_SECONDS, SCREENSHOT_DELAY_SECONDS); 641 Log.i(TAG, msg); 642 // Show a toast just once, otherwise it might be captured in the screenshot. 643 Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show(); 644 645 takeScreenshot(id, SCREENSHOT_DELAY_SECONDS); 646 } else { 647 takeScreenshot(id, 0); 648 } 649 } 650 651 /** 652 * Takes a screenshot after {@code delay} seconds. 653 */ 654 private void takeScreenshot(int id, int delay) { 655 if (delay > 0) { 656 Log.d(TAG, "Taking screenshot for " + id + " in " + delay + " seconds"); 657 final Message msg = mMainHandler.obtainMessage(); 658 msg.what = MSG_DELAYED_SCREENSHOT; 659 msg.arg1 = id; 660 msg.arg2 = delay - 1; 661 mMainHandler.sendMessageDelayed(msg, DateUtils.SECOND_IN_MILLIS); 662 return; 663 } 664 665 // It's time to take the screenshot: let the proper thread handle it 666 final BugreportInfo info = getInfo(id); 667 if (info == null) { 668 return; 669 } 670 final String screenshotPath = 671 new File(mScreenshotsDir, info.getPathNextScreenshot()).getAbsolutePath(); 672 673 Message.obtain(mScreenshotHandler, MSG_SCREENSHOT_REQUEST, id, UNUSED_ARG2, screenshotPath) 674 .sendToTarget(); 675 } 676 677 /** 678 * Sets the internal {@code mTakingScreenshot} state and updates all notifications so their 679 * SCREENSHOT button is enabled or disabled accordingly. 680 */ 681 private void setTakingScreenshot(boolean flag) { 682 synchronized (BugreportProgressService.this) { 683 mTakingScreenshot = flag; 684 for (int i = 0; i < mProcesses.size(); i++) { 685 final BugreportInfo info = mProcesses.valueAt(i); 686 if (info.finished) { 687 Log.d(TAG, "Not updating progress because share notification was already sent"); 688 continue; 689 } 690 updateProgress(info); 691 } 692 } 693 } 694 695 private void handleScreenshotRequest(Message requestMsg) { 696 String screenshotFile = (String) requestMsg.obj; 697 boolean taken = takeScreenshot(mContext, screenshotFile); 698 setTakingScreenshot(false); 699 700 Message.obtain(mMainHandler, MSG_SCREENSHOT_RESPONSE, requestMsg.arg1, taken ? 1 : 0, 701 screenshotFile).sendToTarget(); 702 } 703 704 private void handleScreenshotResponse(Message resultMsg) { 705 final boolean taken = resultMsg.arg2 != 0; 706 final BugreportInfo info = getInfo(resultMsg.arg1); 707 if (info == null) { 708 return; 709 } 710 final File screenshotFile = new File((String) resultMsg.obj); 711 712 final String msg; 713 if (taken) { 714 info.addScreenshot(screenshotFile); 715 if (info.finished) { 716 Log.d(TAG, "Screenshot finished after bugreport; updating share notification"); 717 info.renameScreenshots(mScreenshotsDir); 718 sendBugreportNotification(mContext, info); 719 } 720 msg = mContext.getString(R.string.bugreport_screenshot_taken); 721 } else { 722 // TODO: try again using Framework APIs instead of relying on screencap. 723 msg = mContext.getString(R.string.bugreport_screenshot_failed); 724 Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show(); 725 } 726 Log.d(TAG, msg); 727 } 728 729 /** 730 * Deletes all screenshots taken for a given bugreport. 731 */ 732 private void deleteScreenshots(BugreportInfo info) { 733 for (File file : info.screenshotFiles) { 734 Log.i(TAG, "Deleting screenshot file " + file); 735 file.delete(); 736 } 737 } 738 739 /** 740 * Finishes the service when it's not monitoring any more processes. 741 */ 742 private void stopSelfWhenDone() { 743 if (mProcesses.size() > 0) { 744 if (DEBUG) Log.d(TAG, "Staying alive, waiting for IDs " + mProcesses); 745 return; 746 } 747 Log.v(TAG, "No more processes to handle, shutting down"); 748 stopSelf(); 749 } 750 751 /** 752 * Handles the BUGREPORT_FINISHED intent sent by {@code dumpstate}. 753 */ 754 private void onBugreportFinished(int id, Intent intent) { 755 final File bugreportFile = getFileExtra(intent, EXTRA_BUGREPORT); 756 if (bugreportFile == null) { 757 // Should never happen, dumpstate always set the file. 758 Log.wtf(TAG, "Missing " + EXTRA_BUGREPORT + " on intent " + intent); 759 return; 760 } 761 mInfoDialog.onBugreportFinished(id); 762 BugreportInfo info = getInfo(id); 763 if (info == null) { 764 // Happens when BUGREPORT_FINISHED was received without a BUGREPORT_STARTED first. 765 Log.v(TAG, "Creating info for untracked ID " + id); 766 info = new BugreportInfo(mContext, id); 767 mProcesses.put(id, info); 768 } 769 info.renameScreenshots(mScreenshotsDir); 770 info.bugreportFile = bugreportFile; 771 772 final int max = intent.getIntExtra(EXTRA_MAX, -1); 773 if (max != -1) { 774 MetricsLogger.histogram(this, "dumpstate_duration", max); 775 info.max = max; 776 } 777 778 final File screenshot = getFileExtra(intent, EXTRA_SCREENSHOT); 779 if (screenshot != null) { 780 info.addScreenshot(screenshot); 781 } 782 info.finished = true; 783 784 final Configuration conf = mContext.getResources().getConfiguration(); 785 if ((conf.uiMode & Configuration.UI_MODE_TYPE_MASK) != Configuration.UI_MODE_TYPE_WATCH) { 786 triggerLocalNotification(mContext, info); 787 } 788 } 789 790 /** 791 * Responsible for triggering a notification that allows the user to start a "share" intent with 792 * the bugreport. On watches we have other methods to allow the user to start this intent 793 * (usually by triggering it on another connected device); we don't need to display the 794 * notification in this case. 795 */ 796 private void triggerLocalNotification(final Context context, final BugreportInfo info) { 797 if (!info.bugreportFile.exists() || !info.bugreportFile.canRead()) { 798 Log.e(TAG, "Could not read bugreport file " + info.bugreportFile); 799 Toast.makeText(context, R.string.bugreport_unreadable_text, Toast.LENGTH_LONG).show(); 800 stopProgress(info.id); 801 return; 802 } 803 804 boolean isPlainText = info.bugreportFile.getName().toLowerCase().endsWith(".txt"); 805 if (!isPlainText) { 806 // Already zipped, send it right away. 807 sendBugreportNotification(context, info); 808 } else { 809 // Asynchronously zip the file first, then send it. 810 sendZippedBugreportNotification(context, info); 811 } 812 } 813 814 private static Intent buildWarningIntent(Context context, Intent sendIntent) { 815 final Intent intent = new Intent(context, BugreportWarningActivity.class); 816 intent.putExtra(Intent.EXTRA_INTENT, sendIntent); 817 return intent; 818 } 819 820 /** 821 * Build {@link Intent} that can be used to share the given bugreport. 822 */ 823 private static Intent buildSendIntent(Context context, BugreportInfo info) { 824 // Files are kept on private storage, so turn into Uris that we can 825 // grant temporary permissions for. 826 final Uri bugreportUri = getUri(context, info.bugreportFile); 827 828 final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE); 829 final String mimeType = "application/vnd.android.bugreport"; 830 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); 831 intent.addCategory(Intent.CATEGORY_DEFAULT); 832 intent.setType(mimeType); 833 834 final String subject = !TextUtils.isEmpty(info.title) ? 835 info.title : bugreportUri.getLastPathSegment(); 836 intent.putExtra(Intent.EXTRA_SUBJECT, subject); 837 838 // EXTRA_TEXT should be an ArrayList, but some clients are expecting a single String. 839 // So, to avoid an exception on Intent.migrateExtraStreamToClipData(), we need to manually 840 // create the ClipData object with the attachments URIs. 841 final StringBuilder messageBody = new StringBuilder("Build info: ") 842 .append(SystemProperties.get("ro.build.description")) 843 .append("\nSerial number: ") 844 .append(SystemProperties.get("ro.serialno")); 845 if (!TextUtils.isEmpty(info.description)) { 846 messageBody.append("\nDescription: ").append(info.description); 847 } 848 intent.putExtra(Intent.EXTRA_TEXT, messageBody.toString()); 849 final ClipData clipData = new ClipData(null, new String[] { mimeType }, 850 new ClipData.Item(null, null, null, bugreportUri)); 851 final ArrayList<Uri> attachments = Lists.newArrayList(bugreportUri); 852 for (File screenshot : info.screenshotFiles) { 853 final Uri screenshotUri = getUri(context, screenshot); 854 clipData.addItem(new ClipData.Item(null, null, null, screenshotUri)); 855 attachments.add(screenshotUri); 856 } 857 intent.setClipData(clipData); 858 intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, attachments); 859 860 final Account sendToAccount = findSendToAccount(context); 861 if (sendToAccount != null) { 862 intent.putExtra(Intent.EXTRA_EMAIL, new String[] { sendToAccount.name }); 863 } 864 865 return intent; 866 } 867 868 /** 869 * Shares the bugreport upon user's request by issuing a {@link Intent#ACTION_SEND_MULTIPLE} 870 * intent, but issuing a warning dialog the first time. 871 */ 872 private void shareBugreport(int id, BugreportInfo sharedInfo) { 873 MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_SHARE); 874 BugreportInfo info = getInfo(id); 875 if (info == null) { 876 // Service was terminated but notification persisted 877 info = sharedInfo; 878 Log.d(TAG, "shareBugreport(): no info for ID " + id + " on managed processes (" 879 + mProcesses + "), using info from intent instead (" + info + ")"); 880 } 881 882 addDetailsToZipFile(mContext, info); 883 884 final Intent sendIntent = buildSendIntent(mContext, info); 885 final Intent notifIntent; 886 887 // Send through warning dialog by default 888 if (getWarningState(mContext, STATE_SHOW) == STATE_SHOW) { 889 notifIntent = buildWarningIntent(mContext, sendIntent); 890 } else { 891 notifIntent = sendIntent; 892 } 893 notifIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 894 895 // Send the share intent... 896 mContext.startActivity(notifIntent); 897 898 // ... and stop watching this process. 899 stopProgress(id); 900 } 901 902 /** 903 * Sends a notification indicating the bugreport has finished so use can share it. 904 */ 905 private static void sendBugreportNotification(Context context, BugreportInfo info) { 906 907 // Since adding the details can take a while, do it before notifying user. 908 addDetailsToZipFile(context, info); 909 910 final Intent shareIntent = new Intent(INTENT_BUGREPORT_SHARE); 911 shareIntent.setClass(context, BugreportProgressService.class); 912 shareIntent.setAction(INTENT_BUGREPORT_SHARE); 913 shareIntent.putExtra(EXTRA_ID, info.id); 914 shareIntent.putExtra(EXTRA_INFO, info); 915 916 final String title = context.getString(R.string.bugreport_finished_title, info.id); 917 final Notification.Builder builder = new Notification.Builder(context) 918 .setSmallIcon(com.android.internal.R.drawable.stat_sys_adb) 919 .setContentTitle(title) 920 .setTicker(title) 921 .setContentText(context.getString(R.string.bugreport_finished_text)) 922 .setContentIntent(PendingIntent.getService(context, info.id, shareIntent, 923 PendingIntent.FLAG_UPDATE_CURRENT)) 924 .setDeleteIntent(newCancelIntent(context, info)) 925 .setLocalOnly(true) 926 .setColor(context.getColor( 927 com.android.internal.R.color.system_notification_accent_color)); 928 929 if (!TextUtils.isEmpty(info.name)) { 930 builder.setContentInfo(info.name); 931 } 932 933 Log.v(TAG, "Sending 'Share' notification for ID " + info.id + ": " + title); 934 NotificationManager.from(context).notify(TAG, info.id, builder.build()); 935 } 936 937 /** 938 * Sends a notification indicating the bugreport is being updated so the user can wait until it 939 * finishes - at this point there is nothing to be done other than waiting, hence it has no 940 * pending action. 941 */ 942 private static void sendBugreportBeingUpdatedNotification(Context context, int id) { 943 final String title = context.getString(R.string.bugreport_updating_title); 944 final Notification.Builder builder = new Notification.Builder(context) 945 .setSmallIcon(com.android.internal.R.drawable.stat_sys_adb) 946 .setContentTitle(title) 947 .setTicker(title) 948 .setContentText(context.getString(R.string.bugreport_updating_wait)) 949 .setLocalOnly(true) 950 .setColor(context.getColor( 951 com.android.internal.R.color.system_notification_accent_color)); 952 Log.v(TAG, "Sending 'Updating zip' notification for ID " + id + ": " + title); 953 NotificationManager.from(context).notify(TAG, id, builder.build()); 954 } 955 956 /** 957 * Sends a zipped bugreport notification. 958 */ 959 private static void sendZippedBugreportNotification(final Context context, 960 final BugreportInfo info) { 961 new AsyncTask<Void, Void, Void>() { 962 @Override 963 protected Void doInBackground(Void... params) { 964 zipBugreport(info); 965 sendBugreportNotification(context, info); 966 return null; 967 } 968 }.execute(); 969 } 970 971 /** 972 * Zips a bugreport file, returning the path to the new file (or to the 973 * original in case of failure). 974 */ 975 private static void zipBugreport(BugreportInfo info) { 976 final String bugreportPath = info.bugreportFile.getAbsolutePath(); 977 final String zippedPath = bugreportPath.replace(".txt", ".zip"); 978 Log.v(TAG, "zipping " + bugreportPath + " as " + zippedPath); 979 final File bugreportZippedFile = new File(zippedPath); 980 try (InputStream is = new FileInputStream(info.bugreportFile); 981 ZipOutputStream zos = new ZipOutputStream( 982 new BufferedOutputStream(new FileOutputStream(bugreportZippedFile)))) { 983 addEntry(zos, info.bugreportFile.getName(), is); 984 // Delete old file 985 final boolean deleted = info.bugreportFile.delete(); 986 if (deleted) { 987 Log.v(TAG, "deleted original bugreport (" + bugreportPath + ")"); 988 } else { 989 Log.e(TAG, "could not delete original bugreport (" + bugreportPath + ")"); 990 } 991 info.bugreportFile = bugreportZippedFile; 992 } catch (IOException e) { 993 Log.e(TAG, "exception zipping file " + zippedPath, e); 994 } 995 } 996 997 /** 998 * Adds the user-provided info into the bugreport zip file. 999 * <p> 1000 * If user provided a title, it will be saved into a {@code title.txt} entry; similarly, the 1001 * description will be saved on {@code description.txt}. 1002 */ 1003 private static void addDetailsToZipFile(Context context, BugreportInfo info) { 1004 if (info.bugreportFile == null) { 1005 // One possible reason is a bug in the Parcelization code. 1006 Log.wtf(TAG, "addDetailsToZipFile(): no bugreportFile on " + info); 1007 return; 1008 } 1009 if (TextUtils.isEmpty(info.title) && TextUtils.isEmpty(info.description)) { 1010 Log.d(TAG, "Not touching zip file since neither title nor description are set"); 1011 return; 1012 } 1013 if (info.addedDetailsToZip || info.addingDetailsToZip) { 1014 Log.d(TAG, "Already added details to zip file for " + info); 1015 return; 1016 } 1017 info.addingDetailsToZip = true; 1018 1019 // It's not possible to add a new entry into an existing file, so we need to create a new 1020 // zip, copy all entries, then rename it. 1021 sendBugreportBeingUpdatedNotification(context, info.id); // ...and that takes time 1022 final File dir = info.bugreportFile.getParentFile(); 1023 final File tmpZip = new File(dir, "tmp-" + info.bugreportFile.getName()); 1024 Log.d(TAG, "Writing temporary zip file (" + tmpZip + ") with title and/or description"); 1025 try (ZipFile oldZip = new ZipFile(info.bugreportFile); 1026 ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(tmpZip))) { 1027 1028 // First copy contents from original zip. 1029 Enumeration<? extends ZipEntry> entries = oldZip.entries(); 1030 while (entries.hasMoreElements()) { 1031 final ZipEntry entry = entries.nextElement(); 1032 final String entryName = entry.getName(); 1033 if (!entry.isDirectory()) { 1034 addEntry(zos, entryName, entry.getTime(), oldZip.getInputStream(entry)); 1035 } else { 1036 Log.w(TAG, "skipping directory entry: " + entryName); 1037 } 1038 } 1039 1040 // Then add the user-provided info. 1041 addEntry(zos, "title.txt", info.title); 1042 addEntry(zos, "description.txt", info.description); 1043 } catch (IOException e) { 1044 info.addingDetailsToZip = false; 1045 Log.e(TAG, "exception zipping file " + tmpZip, e); 1046 return; 1047 } 1048 1049 if (!tmpZip.renameTo(info.bugreportFile)) { 1050 Log.e(TAG, "Could not rename " + tmpZip + " to " + info.bugreportFile); 1051 } 1052 info.addedDetailsToZip = true; 1053 info.addingDetailsToZip = false; 1054 } 1055 1056 private static void addEntry(ZipOutputStream zos, String entry, String text) 1057 throws IOException { 1058 if (DEBUG) Log.v(TAG, "adding entry '" + entry + "': " + text); 1059 if (!TextUtils.isEmpty(text)) { 1060 addEntry(zos, entry, new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8))); 1061 } 1062 } 1063 1064 private static void addEntry(ZipOutputStream zos, String entryName, InputStream is) 1065 throws IOException { 1066 addEntry(zos, entryName, System.currentTimeMillis(), is); 1067 } 1068 1069 private static void addEntry(ZipOutputStream zos, String entryName, long timestamp, 1070 InputStream is) throws IOException { 1071 final ZipEntry entry = new ZipEntry(entryName); 1072 entry.setTime(timestamp); 1073 zos.putNextEntry(entry); 1074 final int totalBytes = Streams.copy(is, zos); 1075 if (DEBUG) Log.v(TAG, "size of '" + entryName + "' entry: " + totalBytes + " bytes"); 1076 zos.closeEntry(); 1077 } 1078 1079 /** 1080 * Find the best matching {@link Account} based on build properties. 1081 */ 1082 private static Account findSendToAccount(Context context) { 1083 final AccountManager am = (AccountManager) context.getSystemService( 1084 Context.ACCOUNT_SERVICE); 1085 1086 String preferredDomain = SystemProperties.get("sendbug.preferred.domain"); 1087 if (!preferredDomain.startsWith("@")) { 1088 preferredDomain = "@" + preferredDomain; 1089 } 1090 1091 final Account[] accounts = am.getAccounts(); 1092 Account foundAccount = null; 1093 for (Account account : accounts) { 1094 if (Patterns.EMAIL_ADDRESS.matcher(account.name).matches()) { 1095 if (!preferredDomain.isEmpty()) { 1096 // if we have a preferred domain and it matches, return; otherwise keep 1097 // looking 1098 if (account.name.endsWith(preferredDomain)) { 1099 return account; 1100 } else { 1101 foundAccount = account; 1102 } 1103 // if we don't have a preferred domain, just return since it looks like 1104 // an email address 1105 } else { 1106 return account; 1107 } 1108 } 1109 } 1110 return foundAccount; 1111 } 1112 1113 static Uri getUri(Context context, File file) { 1114 return file != null ? FileProvider.getUriForFile(context, AUTHORITY, file) : null; 1115 } 1116 1117 static File getFileExtra(Intent intent, String key) { 1118 final String path = intent.getStringExtra(key); 1119 if (path != null) { 1120 return new File(path); 1121 } else { 1122 return null; 1123 } 1124 } 1125 1126 private static boolean setSystemProperty(String key, String value) { 1127 try { 1128 if (DEBUG) Log.v(TAG, "Setting system property " + key + " to " + value); 1129 SystemProperties.set(key, value); 1130 } catch (IllegalArgumentException e) { 1131 Log.e(TAG, "Could not set property " + key + " to " + value, e); 1132 return false; 1133 } 1134 return true; 1135 } 1136 1137 /** 1138 * Updates the system property used by {@code dumpstate} to rename the final bugreport files. 1139 */ 1140 private boolean setBugreportNameProperty(int pid, String name) { 1141 Log.d(TAG, "Updating bugreport name to " + name); 1142 final String key = DUMPSTATE_PREFIX + pid + NAME_SUFFIX; 1143 return setSystemProperty(key, name); 1144 } 1145 1146 /** 1147 * Updates the user-provided details of a bugreport. 1148 */ 1149 private void updateBugreportInfo(int id, String name, String title, String description) { 1150 final BugreportInfo info = getInfo(id); 1151 if (info == null) { 1152 return; 1153 } 1154 if (title != null && !title.equals(info.title)) { 1155 MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_TITLE_CHANGED); 1156 } 1157 info.title = title; 1158 if (description != null && !description.equals(info.description)) { 1159 MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_DESCRIPTION_CHANGED); 1160 } 1161 info.description = description; 1162 if (name != null && !name.equals(info.name)) { 1163 MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_NAME_CHANGED); 1164 info.name = name; 1165 updateProgress(info); 1166 } 1167 } 1168 1169 private void collapseNotificationBar() { 1170 sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); 1171 } 1172 1173 private static Looper newLooper(String name) { 1174 final HandlerThread thread = new HandlerThread(name, THREAD_PRIORITY_BACKGROUND); 1175 thread.start(); 1176 return thread.getLooper(); 1177 } 1178 1179 /** 1180 * Takes a screenshot and save it to the given location. 1181 */ 1182 private static boolean takeScreenshot(Context context, String screenshotFile) { 1183 ((Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE)) 1184 .vibrate(150); 1185 final ProcessBuilder screencap = new ProcessBuilder() 1186 .command("/system/bin/screencap", "-p", screenshotFile); 1187 Log.d(TAG, "Taking screenshot using " + screencap.command()); 1188 try { 1189 final int exitValue = screencap.start().waitFor(); 1190 if (exitValue == 0) { 1191 return true; 1192 } 1193 Log.e(TAG, "screencap (" + screencap.command() + ") failed: " + exitValue); 1194 } catch (IOException e) { 1195 Log.e(TAG, "screencap (" + screencap.command() + ") failed", e); 1196 } catch (InterruptedException e) { 1197 Log.w(TAG, "Thread interrupted while screencap still running"); 1198 Thread.currentThread().interrupt(); 1199 } 1200 return false; 1201 } 1202 1203 /** 1204 * Checks whether a character is valid on bugreport names. 1205 */ 1206 @VisibleForTesting 1207 static boolean isValid(char c) { 1208 return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') 1209 || c == '_' || c == '-'; 1210 } 1211 1212 /** 1213 * Helper class encapsulating the UI elements and logic used to display a dialog where user 1214 * can change the details of a bugreport. 1215 */ 1216 private final class BugreportInfoDialog { 1217 private EditText mInfoName; 1218 private EditText mInfoTitle; 1219 private EditText mInfoDescription; 1220 private AlertDialog mDialog; 1221 private Button mOkButton; 1222 private int mId; 1223 private int mPid; 1224 1225 /** 1226 * Last "committed" value of the bugreport name. 1227 * <p> 1228 * Once initially set, it's only updated when user clicks the OK button. 1229 */ 1230 private String mSavedName; 1231 1232 /** 1233 * Last value of the bugreport name as entered by the user. 1234 * <p> 1235 * Every time it's changed the equivalent system property is changed as well, but if the 1236 * user clicks CANCEL, the old value (stored on {@code mSavedName} is restored. 1237 * <p> 1238 * This logic handles the corner-case scenario where {@code dumpstate} finishes after the 1239 * user changed the name but didn't clicked OK yet (for example, because the user is typing 1240 * the description). The only drawback is that if the user changes the name while 1241 * {@code dumpstate} is running but clicks CANCEL after it finishes, then the final name 1242 * will be the one that has been canceled. But when {@code dumpstate} finishes the {code 1243 * name} UI is disabled and the old name restored anyways, so the user will be "alerted" of 1244 * such drawback. 1245 */ 1246 private String mTempName; 1247 1248 /** 1249 * Sets its internal state and displays the dialog. 1250 */ 1251 private void initialize(final Context context, BugreportInfo info) { 1252 final String dialogTitle = 1253 context.getString(R.string.bugreport_info_dialog_title, info.id); 1254 // First initializes singleton. 1255 if (mDialog == null) { 1256 @SuppressLint("InflateParams") 1257 // It's ok pass null ViewRoot on AlertDialogs. 1258 final View view = View.inflate(context, R.layout.dialog_bugreport_info, null); 1259 1260 mInfoName = (EditText) view.findViewById(R.id.name); 1261 mInfoTitle = (EditText) view.findViewById(R.id.title); 1262 mInfoDescription = (EditText) view.findViewById(R.id.description); 1263 1264 mInfoName.setOnFocusChangeListener(new OnFocusChangeListener() { 1265 1266 @Override 1267 public void onFocusChange(View v, boolean hasFocus) { 1268 if (hasFocus) { 1269 return; 1270 } 1271 // Select-all is useful just initially, since the date-based filename is 1272 // full of hyphens. 1273 mInfoName.setSelectAllOnFocus(false); 1274 sanitizeName(); 1275 } 1276 }); 1277 1278 mDialog = new AlertDialog.Builder(context) 1279 .setView(view) 1280 .setTitle(dialogTitle) 1281 .setCancelable(false) 1282 .setPositiveButton(context.getString(R.string.save), 1283 null) 1284 .setNegativeButton(context.getString(com.android.internal.R.string.cancel), 1285 new DialogInterface.OnClickListener() 1286 { 1287 @Override 1288 public void onClick(DialogInterface dialog, int id) 1289 { 1290 MetricsLogger.action(context, 1291 MetricsEvent.ACTION_BUGREPORT_DETAILS_CANCELED); 1292 if (!mTempName.equals(mSavedName)) { 1293 // Must restore dumpstate's name since it was changed 1294 // before user clicked OK. 1295 setBugreportNameProperty(mPid, mSavedName); 1296 } 1297 } 1298 }) 1299 .create(); 1300 1301 mDialog.getWindow().setAttributes( 1302 new WindowManager.LayoutParams( 1303 WindowManager.LayoutParams.TYPE_SYSTEM_DIALOG)); 1304 1305 } else { 1306 // Re-use view, but reset fields first. 1307 mDialog.setTitle(dialogTitle); 1308 mInfoName.setText(null); 1309 mInfoTitle.setText(null); 1310 mInfoDescription.setText(null); 1311 } 1312 1313 // Then set fields. 1314 mSavedName = mTempName = info.name; 1315 mId = info.id; 1316 mPid = info.pid; 1317 if (!TextUtils.isEmpty(info.name)) { 1318 mInfoName.setText(info.name); 1319 } 1320 if (!TextUtils.isEmpty(info.title)) { 1321 mInfoTitle.setText(info.title); 1322 } 1323 if (!TextUtils.isEmpty(info.description)) { 1324 mInfoDescription.setText(info.description); 1325 } 1326 1327 // And finally display it. 1328 mDialog.show(); 1329 1330 // TODO: in a traditional AlertDialog, when the positive button is clicked the 1331 // dialog is always closed, but we need to validate the name first, so we need to 1332 // get a reference to it, which is only available after it's displayed. 1333 // It would be cleaner to use a regular dialog instead, but let's keep this 1334 // workaround for now and change it later, when we add another button to take 1335 // extra screenshots. 1336 if (mOkButton == null) { 1337 mOkButton = mDialog.getButton(DialogInterface.BUTTON_POSITIVE); 1338 mOkButton.setOnClickListener(new View.OnClickListener() { 1339 1340 @Override 1341 public void onClick(View view) { 1342 MetricsLogger.action(context, MetricsEvent.ACTION_BUGREPORT_DETAILS_SAVED); 1343 sanitizeName(); 1344 final String name = mInfoName.getText().toString(); 1345 final String title = mInfoTitle.getText().toString(); 1346 final String description = mInfoDescription.getText().toString(); 1347 1348 updateBugreportInfo(mId, name, title, description); 1349 mDialog.dismiss(); 1350 } 1351 }); 1352 } 1353 } 1354 1355 /** 1356 * Sanitizes the user-provided value for the {@code name} field, automatically replacing 1357 * invalid characters if necessary. 1358 */ 1359 private void sanitizeName() { 1360 String name = mInfoName.getText().toString(); 1361 if (name.equals(mTempName)) { 1362 if (DEBUG) Log.v(TAG, "name didn't change, no need to sanitize: " + name); 1363 return; 1364 } 1365 final StringBuilder safeName = new StringBuilder(name.length()); 1366 boolean changed = false; 1367 for (int i = 0; i < name.length(); i++) { 1368 final char c = name.charAt(i); 1369 if (isValid(c)) { 1370 safeName.append(c); 1371 } else { 1372 changed = true; 1373 safeName.append('_'); 1374 } 1375 } 1376 if (changed) { 1377 Log.v(TAG, "changed invalid name '" + name + "' to '" + safeName + "'"); 1378 name = safeName.toString(); 1379 mInfoName.setText(name); 1380 } 1381 mTempName = name; 1382 1383 // Must update system property for the cases where dumpstate finishes 1384 // while the user is still entering other fields (like title or 1385 // description) 1386 setBugreportNameProperty(mPid, name); 1387 } 1388 1389 /** 1390 * Notifies the dialog that the bugreport has finished so it disables the {@code name} 1391 * field. 1392 * <p>Once the bugreport is finished dumpstate has already generated the final files, so 1393 * changing the name would have no effect. 1394 */ 1395 private void onBugreportFinished(int id) { 1396 if (mInfoName != null) { 1397 mInfoName.setEnabled(false); 1398 mInfoName.setText(mSavedName); 1399 } 1400 } 1401 1402 } 1403 1404 /** 1405 * Information about a bugreport process while its in progress. 1406 */ 1407 private static final class BugreportInfo implements Parcelable { 1408 private final Context context; 1409 1410 /** 1411 * Sequential, user-friendly id used to identify the bugreport. 1412 */ 1413 final int id; 1414 1415 /** 1416 * {@code pid} of the {@code dumpstate} process generating the bugreport. 1417 */ 1418 final int pid; 1419 1420 /** 1421 * Name of the bugreport, will be used to rename the final files. 1422 * <p> 1423 * Initial value is the bugreport filename reported by {@code dumpstate}, but user can 1424 * change it later to a more meaningful name. 1425 */ 1426 String name; 1427 1428 /** 1429 * User-provided, one-line summary of the bug; when set, will be used as the subject 1430 * of the {@link Intent#ACTION_SEND_MULTIPLE} intent. 1431 */ 1432 String title; 1433 1434 /** 1435 * User-provided, detailed description of the bugreport; when set, will be added to the body 1436 * of the {@link Intent#ACTION_SEND_MULTIPLE} intent. 1437 */ 1438 String description; 1439 1440 /** 1441 * Maximum progress of the bugreport generation. 1442 */ 1443 int max; 1444 1445 /** 1446 * Current progress of the bugreport generation. 1447 */ 1448 int progress; 1449 1450 /** 1451 * Time of the last progress update. 1452 */ 1453 long lastUpdate = System.currentTimeMillis(); 1454 1455 /** 1456 * Time of the last progress update when Parcel was created. 1457 */ 1458 String formattedLastUpdate; 1459 1460 /** 1461 * Path of the main bugreport file. 1462 */ 1463 File bugreportFile; 1464 1465 /** 1466 * Path of the screenshot files. 1467 */ 1468 List<File> screenshotFiles = new ArrayList<>(1); 1469 1470 /** 1471 * Whether dumpstate sent an intent informing it has finished. 1472 */ 1473 boolean finished; 1474 1475 /** 1476 * Whether the details entries have been added to the bugreport yet. 1477 */ 1478 boolean addingDetailsToZip; 1479 boolean addedDetailsToZip; 1480 1481 /** 1482 * Internal counter used to name screenshot files. 1483 */ 1484 int screenshotCounter; 1485 1486 /** 1487 * Constructor for tracked bugreports - typically called upon receiving BUGREPORT_STARTED. 1488 */ 1489 BugreportInfo(Context context, int id, int pid, String name, int max) { 1490 this.context = context; 1491 this.id = id; 1492 this.pid = pid; 1493 this.name = name; 1494 this.max = max; 1495 } 1496 1497 /** 1498 * Constructor for untracked bugreports - typically called upon receiving BUGREPORT_FINISHED 1499 * without a previous call to BUGREPORT_STARTED. 1500 */ 1501 BugreportInfo(Context context, int id) { 1502 this(context, id, id, null, 0); 1503 this.finished = true; 1504 } 1505 1506 /** 1507 * Gets the name for next screenshot file. 1508 */ 1509 String getPathNextScreenshot() { 1510 screenshotCounter ++; 1511 return "screenshot-" + pid + "-" + screenshotCounter + ".png"; 1512 } 1513 1514 /** 1515 * Saves the location of a taken screenshot so it can be sent out at the end. 1516 */ 1517 void addScreenshot(File screenshot) { 1518 screenshotFiles.add(screenshot); 1519 } 1520 1521 /** 1522 * Rename all screenshots files so that they contain the user-generated name instead of pid. 1523 */ 1524 void renameScreenshots(File screenshotDir) { 1525 if (TextUtils.isEmpty(name)) { 1526 return; 1527 } 1528 final List<File> renamedFiles = new ArrayList<>(screenshotFiles.size()); 1529 for (File oldFile : screenshotFiles) { 1530 final String oldName = oldFile.getName(); 1531 final String newName = oldName.replaceFirst(Integer.toString(pid), name); 1532 final File newFile; 1533 if (!newName.equals(oldName)) { 1534 final File renamedFile = new File(screenshotDir, newName); 1535 newFile = oldFile.renameTo(renamedFile) ? renamedFile : oldFile; 1536 } else { 1537 Log.w(TAG, "Name didn't change: " + oldName); // Shouldn't happen. 1538 newFile = oldFile; 1539 } 1540 renamedFiles.add(newFile); 1541 } 1542 screenshotFiles = renamedFiles; 1543 } 1544 1545 String getFormattedLastUpdate() { 1546 if (context == null) { 1547 // Restored from Parcel 1548 return formattedLastUpdate == null ? 1549 Long.toString(lastUpdate) : formattedLastUpdate; 1550 } 1551 return DateUtils.formatDateTime(context, lastUpdate, 1552 DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME); 1553 } 1554 1555 @Override 1556 public String toString() { 1557 final float percent = ((float) progress * 100 / max); 1558 return "id: " + id + ", pid: " + pid + ", name: " + name + ", finished: " + finished 1559 + "\n\ttitle: " + title + "\n\tdescription: " + description 1560 + "\n\tfile: " + bugreportFile + "\n\tscreenshots: " + screenshotFiles 1561 + "\n\tprogress: " + progress + "/" + max + " (" + percent + ")" 1562 + "\n\tlast_update: " + getFormattedLastUpdate() 1563 + "\naddingDetailsToZip: " + addingDetailsToZip 1564 + " addedDetailsToZip: " + addedDetailsToZip; 1565 } 1566 1567 // Parcelable contract 1568 protected BugreportInfo(Parcel in) { 1569 context = null; 1570 id = in.readInt(); 1571 pid = in.readInt(); 1572 name = in.readString(); 1573 title = in.readString(); 1574 description = in.readString(); 1575 max = in.readInt(); 1576 progress = in.readInt(); 1577 lastUpdate = in.readLong(); 1578 formattedLastUpdate = in.readString(); 1579 bugreportFile = readFile(in); 1580 1581 int screenshotSize = in.readInt(); 1582 for (int i = 1; i <= screenshotSize; i++) { 1583 screenshotFiles.add(readFile(in)); 1584 } 1585 1586 finished = in.readInt() == 1; 1587 screenshotCounter = in.readInt(); 1588 } 1589 1590 @Override 1591 public void writeToParcel(Parcel dest, int flags) { 1592 dest.writeInt(id); 1593 dest.writeInt(pid); 1594 dest.writeString(name); 1595 dest.writeString(title); 1596 dest.writeString(description); 1597 dest.writeInt(max); 1598 dest.writeInt(progress); 1599 dest.writeLong(lastUpdate); 1600 dest.writeString(getFormattedLastUpdate()); 1601 writeFile(dest, bugreportFile); 1602 1603 dest.writeInt(screenshotFiles.size()); 1604 for (File screenshotFile : screenshotFiles) { 1605 writeFile(dest, screenshotFile); 1606 } 1607 1608 dest.writeInt(finished ? 1 : 0); 1609 dest.writeInt(screenshotCounter); 1610 } 1611 1612 @Override 1613 public int describeContents() { 1614 return 0; 1615 } 1616 1617 private void writeFile(Parcel dest, File file) { 1618 dest.writeString(file == null ? null : file.getPath()); 1619 } 1620 1621 private File readFile(Parcel in) { 1622 final String path = in.readString(); 1623 return path == null ? null : new File(path); 1624 } 1625 1626 public static final Parcelable.Creator<BugreportInfo> CREATOR = 1627 new Parcelable.Creator<BugreportInfo>() { 1628 public BugreportInfo createFromParcel(Parcel source) { 1629 return new BugreportInfo(source); 1630 } 1631 1632 public BugreportInfo[] newArray(int size) { 1633 return new BugreportInfo[size]; 1634 } 1635 }; 1636 1637 } 1638} 1639