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