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