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