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