/*
 * Copyright (C) 2015 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.android.shell;

import static android.os.Process.THREAD_PRIORITY_BACKGROUND;

import static com.android.shell.BugreportPrefs.STATE_HIDE;
import static com.android.shell.BugreportPrefs.STATE_UNKNOWN;
import static com.android.shell.BugreportPrefs.getWarningState;

import java.io.BufferedOutputStream;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.text.NumberFormat;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;

import libcore.io.Streams;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.app.ChooserActivity;
import com.android.internal.logging.MetricsLogger;
import com.android.internal.logging.nano.MetricsProto.MetricsEvent;
import com.android.internal.util.FastPrintWriter;

import com.google.android.collect.Lists;

import android.accounts.Account;
import android.accounts.AccountManager;
import android.annotation.MainThread;
import android.annotation.SuppressLint;
import android.app.AlertDialog;
import android.app.Notification;
import android.app.Notification.Action;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.app.Service;
import android.content.ClipData;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.IBinder;
import android.os.IBinder.DeathRecipient;
import android.os.IDumpstate;
import android.os.IDumpstateListener;
import android.os.IDumpstateToken;
import android.os.Looper;
import android.os.Message;
import android.os.Parcel;
import android.os.Parcelable;
import android.os.RemoteException;
import android.os.ServiceManager;
import android.os.SystemProperties;
import android.os.UserHandle;
import android.os.UserManager;
import android.os.Vibrator;
import android.support.v4.content.FileProvider;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.util.Log;
import android.util.Pair;
import android.util.Patterns;
import android.util.SparseArray;
import android.view.IWindowManager;
import android.view.View;
import android.view.WindowManager;
import android.view.View.OnFocusChangeListener;
import android.widget.Button;
import android.widget.EditText;
import android.widget.Toast;

Service used to keep progress of bugreport processes (dumpstate).

The workflow is:

  1. When dumpstate starts, it sends a BUGREPORT_STARTED with a sequential id, its pid, and the estimated total effort.
  2. BugreportReceiver receives the intent and delegates it to this service.
  3. Upon start, this service:
    1. Issues a system notification so user can watch the progresss (which is 0% initially).
    2. Polls the SystemProperties for updates on the dumpstate progress.
    3. If the progress changed, it updates the system notification.
  4. As dumpstate progresses, it updates the system property.
  5. When dumpstate finishes, it sends a BUGREPORT_FINISHED intent.
  6. BugreportReceiver receives the intent and delegates it to this service, which in turn:
    1. Updates the system notification so user can share the bugreport.
    2. Stops monitoring that dumpstate process.
    3. Stops itself if it doesn't have any process left to monitor.
TODO: There are multiple threads involved. Add synchronization accordingly.
/** * Service used to keep progress of bugreport processes ({@code dumpstate}). * <p> * The workflow is: * <ol> * <li>When {@code dumpstate} starts, it sends a {@code BUGREPORT_STARTED} with a sequential id, * its pid, and the estimated total effort. * <li>{@link BugreportReceiver} receives the intent and delegates it to this service. * <li>Upon start, this service: * <ol> * <li>Issues a system notification so user can watch the progresss (which is 0% initially). * <li>Polls the {@link SystemProperties} for updates on the {@code dumpstate} progress. * <li>If the progress changed, it updates the system notification. * </ol> * <li>As {@code dumpstate} progresses, it updates the system property. * <li>When {@code dumpstate} finishes, it sends a {@code BUGREPORT_FINISHED} intent. * <li>{@link BugreportReceiver} receives the intent and delegates it to this service, which in * turn: * <ol> * <li>Updates the system notification so user can share the bugreport. * <li>Stops monitoring that {@code dumpstate} process. * <li>Stops itself if it doesn't have any process left to monitor. * </ol> * </ol> * * TODO: There are multiple threads involved. Add synchronization accordingly. */
public class BugreportProgressService extends Service { private static final String TAG = "BugreportProgressService"; private static final boolean DEBUG = false; private static final String AUTHORITY = "com.android.shell"; // External intents sent by dumpstate. static final String INTENT_BUGREPORT_STARTED = "com.android.internal.intent.action.BUGREPORT_STARTED"; static final String INTENT_BUGREPORT_FINISHED = "com.android.internal.intent.action.BUGREPORT_FINISHED"; static final String INTENT_REMOTE_BUGREPORT_FINISHED = "com.android.internal.intent.action.REMOTE_BUGREPORT_FINISHED"; // Internal intents used on notification actions. static final String INTENT_BUGREPORT_CANCEL = "android.intent.action.BUGREPORT_CANCEL"; static final String INTENT_BUGREPORT_SHARE = "android.intent.action.BUGREPORT_SHARE"; static final String INTENT_BUGREPORT_INFO_LAUNCH = "android.intent.action.BUGREPORT_INFO_LAUNCH"; static final String INTENT_BUGREPORT_SCREENSHOT = "android.intent.action.BUGREPORT_SCREENSHOT"; static final String EXTRA_BUGREPORT = "android.intent.extra.BUGREPORT"; static final String EXTRA_SCREENSHOT = "android.intent.extra.SCREENSHOT"; static final String EXTRA_ID = "android.intent.extra.ID"; static final String EXTRA_PID = "android.intent.extra.PID"; static final String EXTRA_MAX = "android.intent.extra.MAX"; static final String EXTRA_NAME = "android.intent.extra.NAME"; static final String EXTRA_TITLE = "android.intent.extra.TITLE"; static final String EXTRA_DESCRIPTION = "android.intent.extra.DESCRIPTION"; static final String EXTRA_ORIGINAL_INTENT = "android.intent.extra.ORIGINAL_INTENT"; static final String EXTRA_INFO = "android.intent.extra.INFO"; private static final int MSG_SERVICE_COMMAND = 1; private static final int MSG_DELAYED_SCREENSHOT = 2; private static final int MSG_SCREENSHOT_REQUEST = 3; private static final int MSG_SCREENSHOT_RESPONSE = 4; // Passed to Message.obtain() when msg.arg2 is not used. private static final int UNUSED_ARG2 = -2; // Maximum progress displayed (like 99.00%). private static final int CAPPED_PROGRESS = 9900; private static final int CAPPED_MAX = 10000;
Show the progress log every this percent.
/** Show the progress log every this percent. */
private static final int LOG_PROGRESS_STEP = 10;
Delay before a screenshot is taken.

Should be at least 3 seconds, otherwise its toast might show up in the screenshot.

/** * Delay before a screenshot is taken. * <p> * Should be at least 3 seconds, otherwise its toast might show up in the screenshot. */
static final int SCREENSHOT_DELAY_SECONDS = 3; // TODO: will be gone once fully migrated to Binder
System properties used to communicate with dumpstate progress.
/** System properties used to communicate with dumpstate progress. */
private static final String DUMPSTATE_PREFIX = "dumpstate."; private static final String NAME_SUFFIX = ".name";
System property (and value) used to stop dumpstate.
/** System property (and value) used to stop dumpstate. */
// TODO: should call ActiveManager API instead private static final String CTL_STOP = "ctl.stop"; private static final String BUGREPORT_SERVICE = "bugreport";
Directory on Shell's data storage where screenshots will be stored.

Must be a path supported by its FileProvider.

/** * Directory on Shell's data storage where screenshots will be stored. * <p> * Must be a path supported by its FileProvider. */
private static final String SCREENSHOT_DIR = "bugreports"; private static final String NOTIFICATION_CHANNEL_ID = "bugreports"; private final Object mLock = new Object();
Managed dumpstate processes (keyed by id)
/** Managed dumpstate processes (keyed by id) */
private final SparseArray<DumpstateListener> mProcesses = new SparseArray<>(); private Context mContext; private Handler mMainThreadHandler; private ServiceHandler mServiceHandler; private ScreenshotHandler mScreenshotHandler; private final BugreportInfoDialog mInfoDialog = new BugreportInfoDialog(); private File mScreenshotsDir;
id of the notification used to set service on foreground.
/** * id of the notification used to set service on foreground. */
private int mForegroundId = -1;
Flag indicating whether a screenshot is being taken.

This is the only state that is shared between the 2 handlers and hence must have synchronized access.

/** * Flag indicating whether a screenshot is being taken. * <p> * This is the only state that is shared between the 2 handlers and hence must have synchronized * access. */
private boolean mTakingScreenshot; private static final Bundle sNotificationBundle = new Bundle(); private boolean mIsWatch; private int mLastProgressPercent; @Override public void onCreate() { mContext = getApplicationContext(); mMainThreadHandler = new Handler(Looper.getMainLooper()); mServiceHandler = new ServiceHandler("BugreportProgressServiceMainThread"); mScreenshotHandler = new ScreenshotHandler("BugreportProgressServiceScreenshotThread"); mScreenshotsDir = new File(getFilesDir(), SCREENSHOT_DIR); if (!mScreenshotsDir.exists()) { Log.i(TAG, "Creating directory " + mScreenshotsDir + " to store temporary screenshots"); if (!mScreenshotsDir.mkdir()) { Log.w(TAG, "Could not create directory " + mScreenshotsDir); } } final Configuration conf = mContext.getResources().getConfiguration(); mIsWatch = (conf.uiMode & Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_WATCH; NotificationManager nm = NotificationManager.from(mContext); nm.createNotificationChannel( new NotificationChannel(NOTIFICATION_CHANNEL_ID, mContext.getString(R.string.bugreport_notification_channel), isTv(this) ? NotificationManager.IMPORTANCE_DEFAULT : NotificationManager.IMPORTANCE_LOW)); } @Override public int onStartCommand(Intent intent, int flags, int startId) { Log.v(TAG, "onStartCommand(): " + dumpIntent(intent)); if (intent != null) { // Handle it in a separate thread. final Message msg = mServiceHandler.obtainMessage(); msg.what = MSG_SERVICE_COMMAND; msg.obj = intent; mServiceHandler.sendMessage(msg); } // If service is killed it cannot be recreated because it would not know which // dumpstate IDs it would have to watch. return START_NOT_STICKY; } @Override public IBinder onBind(Intent intent) { return null; } @Override public void onDestroy() { mServiceHandler.getLooper().quit(); mScreenshotHandler.getLooper().quit(); super.onDestroy(); } @Override protected void dump(FileDescriptor fd, PrintWriter writer, String[] args) { final int size = mProcesses.size(); if (size == 0) { writer.println("No monitored processes"); return; } writer.print("Foreground id: "); writer.println(mForegroundId); writer.println("\n"); writer.println("Monitored dumpstate processes"); writer.println("-----------------------------"); for (int i = 0; i < size; i++) { writer.print("#"); writer.println(i + 1); writer.println(mProcesses.valueAt(i).info); } }
Main thread used to handle all requests but taking screenshots.
/** * Main thread used to handle all requests but taking screenshots. */
private final class ServiceHandler extends Handler { public ServiceHandler(String name) { super(newLooper(name)); } @Override public void handleMessage(Message msg) { if (msg.what == MSG_DELAYED_SCREENSHOT) { takeScreenshot(msg.arg1, msg.arg2); return; } if (msg.what == MSG_SCREENSHOT_RESPONSE) { handleScreenshotResponse(msg); return; } if (msg.what != MSG_SERVICE_COMMAND) { // Sanity check. Log.e(TAG, "Invalid message type: " + msg.what); return; } // At this point it's handling onStartCommand(), with the intent passed as an Extra. if (!(msg.obj instanceof Intent)) { // Sanity check. Log.wtf(TAG, "handleMessage(): invalid msg.obj type: " + msg.obj); return; } final Parcelable parcel = ((Intent) msg.obj).getParcelableExtra(EXTRA_ORIGINAL_INTENT); Log.v(TAG, "handleMessage(): " + dumpIntent((Intent) parcel)); final Intent intent; if (parcel instanceof Intent) { // The real intent was passed to BugreportReceiver, which delegated to the service. intent = (Intent) parcel; } else { intent = (Intent) msg.obj; } final String action = intent.getAction(); final int pid = intent.getIntExtra(EXTRA_PID, 0); final int id = intent.getIntExtra(EXTRA_ID, 0); final int max = intent.getIntExtra(EXTRA_MAX, -1); final String name = intent.getStringExtra(EXTRA_NAME); if (DEBUG) Log.v(TAG, "action: " + action + ", name: " + name + ", id: " + id + ", pid: " + pid + ", max: " + max); switch (action) { case INTENT_BUGREPORT_STARTED: if (!startProgress(name, id, pid, max)) { stopSelfWhenDone(); return; } break; case INTENT_BUGREPORT_FINISHED: if (id == 0) { // Shouldn't happen, unless BUGREPORT_FINISHED is received from a legacy, // out-of-sync dumpstate process. Log.w(TAG, "Missing " + EXTRA_ID + " on intent " + intent); } onBugreportFinished(id, intent); break; case INTENT_BUGREPORT_INFO_LAUNCH: launchBugreportInfoDialog(id); break; case INTENT_BUGREPORT_SCREENSHOT: takeScreenshot(id); break; case INTENT_BUGREPORT_SHARE: shareBugreport(id, (BugreportInfo) intent.getParcelableExtra(EXTRA_INFO)); break; case INTENT_BUGREPORT_CANCEL: cancel(id); break; default: Log.w(TAG, "Unsupported intent: " + action); } return; } }
Separate thread used only to take screenshots so it doesn't block the main thread.
/** * Separate thread used only to take screenshots so it doesn't block the main thread. */
private final class ScreenshotHandler extends Handler { public ScreenshotHandler(String name) { super(newLooper(name)); } @Override public void handleMessage(Message msg) { if (msg.what != MSG_SCREENSHOT_REQUEST) { Log.e(TAG, "Invalid message type: " + msg.what); return; } handleScreenshotRequest(msg); } } private BugreportInfo getInfo(int id) { final DumpstateListener listener = mProcesses.get(id); if (listener == null) { Log.w(TAG, "Not monitoring process with ID " + id); return null; } return listener.info; }
Creates the BugreportInfo for a process and issue a system notification to indicate its progress.
Returns:whether it succeeded or not.
/** * Creates the {@link BugreportInfo} for a process and issue a system notification to * indicate its progress. * * @return whether it succeeded or not. */
private boolean startProgress(String name, int id, int pid, int max) { if (name == null) { Log.w(TAG, "Missing " + EXTRA_NAME + " on start intent"); } if (id == -1) { Log.e(TAG, "Missing " + EXTRA_ID + " on start intent"); return false; } if (pid == -1) { Log.e(TAG, "Missing " + EXTRA_PID + " on start intent"); return false; } if (max <= 0) { Log.e(TAG, "Invalid value for extra " + EXTRA_MAX + ": " + max); return false; } final BugreportInfo info = new BugreportInfo(mContext, id, pid, name, max); if (mProcesses.indexOfKey(id) >= 0) { // BUGREPORT_STARTED intent was already received; ignore it. Log.w(TAG, "ID " + id + " already watched"); return true; } final DumpstateListener listener = new DumpstateListener(info); mProcesses.put(info.id, listener); if (listener.connect()) { updateProgress(info); return true; } else { Log.w(TAG, "not updating progress because it could not connect to dumpstate"); return false; } }
Updates the system notification for a given bugreport.
/** * Updates the system notification for a given bugreport. */
private void updateProgress(BugreportInfo info) { if (info.max <= 0 || info.progress < 0) { Log.e(TAG, "Invalid progress values for " + info); return; } if (info.finished) { Log.w(TAG, "Not sending progress notification because bugreport has finished already (" + info + ")"); return; } final NumberFormat nf = NumberFormat.getPercentInstance(); nf.setMinimumFractionDigits(2); nf.setMaximumFractionDigits(2); final String percentageText = nf.format((double) info.progress / info.max); String title = mContext.getString(R.string.bugreport_in_progress_title, info.id); // TODO: Remove this workaround when notification progress is implemented on Wear. if (mIsWatch) { nf.setMinimumFractionDigits(0); nf.setMaximumFractionDigits(0); final String watchPercentageText = nf.format((double) info.progress / info.max); title = title + "\n" + watchPercentageText; } final String name = info.name != null ? info.name : mContext.getString(R.string.bugreport_unnamed); final Notification.Builder builder = newBaseNotification(mContext) .setContentTitle(title) .setTicker(title) .setContentText(name) .setProgress(info.max, info.progress, false) .setOngoing(true); // Wear bugreport doesn't need the bug info dialog, screenshot and cancel action. if (!mIsWatch) { final Action cancelAction = new Action.Builder(null, mContext.getString( com.android.internal.R.string.cancel), newCancelIntent(mContext, info)).build(); final Intent infoIntent = new Intent(mContext, BugreportProgressService.class); infoIntent.setAction(INTENT_BUGREPORT_INFO_LAUNCH); infoIntent.putExtra(EXTRA_ID, info.id); final PendingIntent infoPendingIntent = PendingIntent.getService(mContext, info.id, infoIntent, PendingIntent.FLAG_UPDATE_CURRENT); final Action infoAction = new Action.Builder(null, mContext.getString(R.string.bugreport_info_action), infoPendingIntent).build(); final Intent screenshotIntent = new Intent(mContext, BugreportProgressService.class); screenshotIntent.setAction(INTENT_BUGREPORT_SCREENSHOT); screenshotIntent.putExtra(EXTRA_ID, info.id); PendingIntent screenshotPendingIntent = mTakingScreenshot ? null : PendingIntent .getService(mContext, info.id, screenshotIntent, PendingIntent.FLAG_UPDATE_CURRENT); final Action screenshotAction = new Action.Builder(null, mContext.getString(R.string.bugreport_screenshot_action), screenshotPendingIntent).build(); builder.setContentIntent(infoPendingIntent) .setActions(infoAction, screenshotAction, cancelAction); } // Show a debug log, every LOG_PROGRESS_STEP percent. final int progress = (info.progress * 100) / info.max; if ((info.progress == 0) || (info.progress >= 100) || ((progress / LOG_PROGRESS_STEP) != (mLastProgressPercent / LOG_PROGRESS_STEP))) { Log.d(TAG, "Progress #" + info.id + ": " + percentageText); } mLastProgressPercent = progress; sendForegroundabledNotification(info.id, builder.build()); } private void sendForegroundabledNotification(int id, Notification notification) { if (mForegroundId >= 0) { if (DEBUG) Log.d(TAG, "Already running as foreground service"); NotificationManager.from(mContext).notify(id, notification); } else { mForegroundId = id; Log.d(TAG, "Start running as foreground service on id " + mForegroundId); startForeground(mForegroundId, notification); } }
Creates a PendingIntent for a notification action used to cancel a bugreport.
/** * Creates a {@link PendingIntent} for a notification action used to cancel a bugreport. */
private static PendingIntent newCancelIntent(Context context, BugreportInfo info) { final Intent intent = new Intent(INTENT_BUGREPORT_CANCEL); intent.setClass(context, BugreportProgressService.class); intent.putExtra(EXTRA_ID, info.id); return PendingIntent.getService(context, info.id, intent, PendingIntent.FLAG_UPDATE_CURRENT); }
Finalizes the progress on a given bugreport and cancel its notification.
/** * Finalizes the progress on a given bugreport and cancel its notification. */
private void stopProgress(int id) { if (mProcesses.indexOfKey(id) < 0) { Log.w(TAG, "ID not watched: " + id); } else { Log.d(TAG, "Removing ID " + id); mProcesses.remove(id); } // Must stop foreground service first, otherwise notif.cancel() will fail below. stopForegroundWhenDone(id); Log.d(TAG, "stopProgress(" + id + "): cancel notification"); NotificationManager.from(mContext).cancel(id); stopSelfWhenDone(); }
Cancels a bugreport upon user's request.
/** * Cancels a bugreport upon user's request. */
private void cancel(int id) { MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_CANCEL); Log.v(TAG, "cancel: ID=" + id); mInfoDialog.cancel(); final BugreportInfo info = getInfo(id); if (info != null && !info.finished) { Log.i(TAG, "Cancelling bugreport service (ID=" + id + ") on user's request"); setSystemProperty(CTL_STOP, BUGREPORT_SERVICE); deleteScreenshots(info); } stopProgress(id); }
Fetches a BugreportInfo for a given process and launches a dialog where the user can change its values.
/** * Fetches a {@link BugreportInfo} for a given process and launches a dialog where the user can * change its values. */
private void launchBugreportInfoDialog(int id) { MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_DETAILS); final BugreportInfo info = getInfo(id); if (info == null) { // Most likely am killed Shell before user tapped the notification. Since system might // be too busy anwyays, it's better to ignore the notification and switch back to the // non-interactive mode (where the bugerport will be shared upon completion). Log.w(TAG, "launchBugreportInfoDialog(): canceling notification because id " + id + " was not found"); // TODO: add test case to make sure notification is canceled. NotificationManager.from(mContext).cancel(id); return; } collapseNotificationBar(); // Dissmiss keyguard first. final IWindowManager wm = IWindowManager.Stub .asInterface(ServiceManager.getService(Context.WINDOW_SERVICE)); try { wm.dismissKeyguard(null, null); } catch (Exception e) { // ignore it } mMainThreadHandler.post(() -> mInfoDialog.initialize(mContext, info)); }
Starting point for taking a screenshot.

It first display a toast message and waits SCREENSHOT_DELAY_SECONDS seconds before taking the screenshot.

/** * Starting point for taking a screenshot. * <p> * It first display a toast message and waits {@link #SCREENSHOT_DELAY_SECONDS} seconds before * taking the screenshot. */
private void takeScreenshot(int id) { MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_SCREENSHOT); if (getInfo(id) == null) { // Most likely am killed Shell before user tapped the notification. Since system might // be too busy anwyays, it's better to ignore the notification and switch back to the // non-interactive mode (where the bugerport will be shared upon completion). Log.w(TAG, "takeScreenshot(): canceling notification because id " + id + " was not found"); // TODO: add test case to make sure notification is canceled. NotificationManager.from(mContext).cancel(id); return; } setTakingScreenshot(true); collapseNotificationBar(); final String msg = mContext.getResources() .getQuantityString(com.android.internal.R.plurals.bugreport_countdown, SCREENSHOT_DELAY_SECONDS, SCREENSHOT_DELAY_SECONDS); Log.i(TAG, msg); // Show a toast just once, otherwise it might be captured in the screenshot. Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show(); takeScreenshot(id, SCREENSHOT_DELAY_SECONDS); }
Takes a screenshot after delay seconds.
/** * Takes a screenshot after {@code delay} seconds. */
private void takeScreenshot(int id, int delay) { if (delay > 0) { Log.d(TAG, "Taking screenshot for " + id + " in " + delay + " seconds"); final Message msg = mServiceHandler.obtainMessage(); msg.what = MSG_DELAYED_SCREENSHOT; msg.arg1 = id; msg.arg2 = delay - 1; mServiceHandler.sendMessageDelayed(msg, DateUtils.SECOND_IN_MILLIS); return; } // It's time to take the screenshot: let the proper thread handle it final BugreportInfo info = getInfo(id); if (info == null) { return; } final String screenshotPath = new File(mScreenshotsDir, info.getPathNextScreenshot()).getAbsolutePath(); Message.obtain(mScreenshotHandler, MSG_SCREENSHOT_REQUEST, id, UNUSED_ARG2, screenshotPath) .sendToTarget(); }
Sets the internal mTakingScreenshot state and updates all notifications so their SCREENSHOT button is enabled or disabled accordingly.
/** * Sets the internal {@code mTakingScreenshot} state and updates all notifications so their * SCREENSHOT button is enabled or disabled accordingly. */
private void setTakingScreenshot(boolean flag) { synchronized (BugreportProgressService.this) { mTakingScreenshot = flag; for (int i = 0; i < mProcesses.size(); i++) { final BugreportInfo info = mProcesses.valueAt(i).info; if (info.finished) { Log.d(TAG, "Not updating progress for " + info.id + " while taking screenshot" + " because share notification was already sent"); continue; } updateProgress(info); } } } private void handleScreenshotRequest(Message requestMsg) { String screenshotFile = (String) requestMsg.obj; boolean taken = takeScreenshot(mContext, screenshotFile); setTakingScreenshot(false); Message.obtain(mServiceHandler, MSG_SCREENSHOT_RESPONSE, requestMsg.arg1, taken ? 1 : 0, screenshotFile).sendToTarget(); } private void handleScreenshotResponse(Message resultMsg) { final boolean taken = resultMsg.arg2 != 0; final BugreportInfo info = getInfo(resultMsg.arg1); if (info == null) { return; } final File screenshotFile = new File((String) resultMsg.obj); final String msg; if (taken) { info.addScreenshot(screenshotFile); if (info.finished) { Log.d(TAG, "Screenshot finished after bugreport; updating share notification"); info.renameScreenshots(mScreenshotsDir); sendBugreportNotification(info, mTakingScreenshot); } msg = mContext.getString(R.string.bugreport_screenshot_taken); } else { msg = mContext.getString(R.string.bugreport_screenshot_failed); Toast.makeText(mContext, msg, Toast.LENGTH_SHORT).show(); } Log.d(TAG, msg); }
Deletes all screenshots taken for a given bugreport.
/** * Deletes all screenshots taken for a given bugreport. */
private void deleteScreenshots(BugreportInfo info) { for (File file : info.screenshotFiles) { Log.i(TAG, "Deleting screenshot file " + file); file.delete(); } }
Stop running on foreground once there is no more active bugreports being watched.
/** * Stop running on foreground once there is no more active bugreports being watched. */
private void stopForegroundWhenDone(int id) { if (id != mForegroundId) { Log.d(TAG, "stopForegroundWhenDone(" + id + "): ignoring since foreground id is " + mForegroundId); return; } Log.d(TAG, "detaching foreground from id " + mForegroundId); stopForeground(Service.STOP_FOREGROUND_DETACH); mForegroundId = -1; // Might need to restart foreground using a new notification id. final int total = mProcesses.size(); if (total > 0) { for (int i = 0; i < total; i++) { final BugreportInfo info = mProcesses.valueAt(i).info; if (!info.finished) { updateProgress(info); break; } } } }
Finishes the service when it's not monitoring any more processes.
/** * Finishes the service when it's not monitoring any more processes. */
private void stopSelfWhenDone() { if (mProcesses.size() > 0) { if (DEBUG) Log.d(TAG, "Staying alive, waiting for IDs " + mProcesses); return; } Log.v(TAG, "No more processes to handle, shutting down"); stopSelf(); }
Handles the BUGREPORT_FINISHED intent sent by dumpstate.
/** * Handles the BUGREPORT_FINISHED intent sent by {@code dumpstate}. */
private void onBugreportFinished(int id, Intent intent) { final File bugreportFile = getFileExtra(intent, EXTRA_BUGREPORT); if (bugreportFile == null) { // Should never happen, dumpstate always set the file. Log.wtf(TAG, "Missing " + EXTRA_BUGREPORT + " on intent " + intent); return; } mInfoDialog.onBugreportFinished(); BugreportInfo info = getInfo(id); if (info == null) { // Happens when BUGREPORT_FINISHED was received without a BUGREPORT_STARTED first. Log.v(TAG, "Creating info for untracked ID " + id); info = new BugreportInfo(mContext, id); mProcesses.put(id, new DumpstateListener(info)); } info.renameScreenshots(mScreenshotsDir); info.bugreportFile = bugreportFile; final int max = intent.getIntExtra(EXTRA_MAX, -1); if (max != -1) { MetricsLogger.histogram(this, "dumpstate_duration", max); info.max = max; } final File screenshot = getFileExtra(intent, EXTRA_SCREENSHOT); if (screenshot != null) { info.addScreenshot(screenshot); } final String shareTitle = intent.getStringExtra(EXTRA_TITLE); if (!TextUtils.isEmpty(shareTitle)) { info.title = shareTitle; final String shareDescription = intent.getStringExtra(EXTRA_DESCRIPTION); if (!TextUtils.isEmpty(shareDescription)) { info.shareDescription= shareDescription; } Log.d(TAG, "Bugreport title is " + info.title + "," + " shareDescription is " + info.shareDescription); } info.finished = true; // Stop running on foreground, otherwise share notification cannot be dismissed. stopForegroundWhenDone(id); triggerLocalNotification(mContext, info); }
Responsible for triggering a notification that allows the user to start a "share" intent with the bugreport. On watches we have other methods to allow the user to start this intent (usually by triggering it on another connected device); we don't need to display the notification in this case.
/** * Responsible for triggering a notification that allows the user to start a "share" intent with * the bugreport. On watches we have other methods to allow the user to start this intent * (usually by triggering it on another connected device); we don't need to display the * notification in this case. */
private void triggerLocalNotification(final Context context, final BugreportInfo info) { if (!info.bugreportFile.exists() || !info.bugreportFile.canRead()) { Log.e(TAG, "Could not read bugreport file " + info.bugreportFile); Toast.makeText(context, R.string.bugreport_unreadable_text, Toast.LENGTH_LONG).show(); stopProgress(info.id); return; } boolean isPlainText = info.bugreportFile.getName().toLowerCase().endsWith(".txt"); if (!isPlainText) { // Already zipped, send it right away. sendBugreportNotification(info, mTakingScreenshot); } else { // Asynchronously zip the file first, then send it. sendZippedBugreportNotification(info, mTakingScreenshot); } } private static Intent buildWarningIntent(Context context, Intent sendIntent) { final Intent intent = new Intent(context, BugreportWarningActivity.class); intent.putExtra(Intent.EXTRA_INTENT, sendIntent); return intent; }
Build Intent that can be used to share the given bugreport.
/** * Build {@link Intent} that can be used to share the given bugreport. */
private static Intent buildSendIntent(Context context, BugreportInfo info) { // Files are kept on private storage, so turn into Uris that we can // grant temporary permissions for. final Uri bugreportUri; try { bugreportUri = getUri(context, info.bugreportFile); } catch (IllegalArgumentException e) { // Should not happen on production, but happens when a Shell is sideloaded and // FileProvider cannot find a configured root for it. Log.wtf(TAG, "Could not get URI for " + info.bugreportFile, e); return null; } final Intent intent = new Intent(Intent.ACTION_SEND_MULTIPLE); final String mimeType = "application/vnd.android.bugreport"; intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); intent.addCategory(Intent.CATEGORY_DEFAULT); intent.setType(mimeType); final String subject = !TextUtils.isEmpty(info.title) ? info.title : bugreportUri.getLastPathSegment(); intent.putExtra(Intent.EXTRA_SUBJECT, subject); // EXTRA_TEXT should be an ArrayList, but some clients are expecting a single String. // So, to avoid an exception on Intent.migrateExtraStreamToClipData(), we need to manually // create the ClipData object with the attachments URIs. final StringBuilder messageBody = new StringBuilder("Build info: ") .append(SystemProperties.get("ro.build.description")) .append("\nSerial number: ") .append(SystemProperties.get("ro.serialno")); int descriptionLength = 0; if (!TextUtils.isEmpty(info.description)) { messageBody.append("\nDescription: ").append(info.description); descriptionLength = info.description.length(); } intent.putExtra(Intent.EXTRA_TEXT, messageBody.toString()); final ClipData clipData = new ClipData(null, new String[] { mimeType }, new ClipData.Item(null, null, null, bugreportUri)); Log.d(TAG, "share intent: bureportUri=" + bugreportUri); final ArrayList<Uri> attachments = Lists.newArrayList(bugreportUri); for (File screenshot : info.screenshotFiles) { final Uri screenshotUri = getUri(context, screenshot); Log.d(TAG, "share intent: screenshotUri=" + screenshotUri); clipData.addItem(new ClipData.Item(null, null, null, screenshotUri)); attachments.add(screenshotUri); } intent.setClipData(clipData); intent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, attachments); final Pair<UserHandle, Account> sendToAccount = findSendToAccount(context, SystemProperties.get("sendbug.preferred.domain")); if (sendToAccount != null) { intent.putExtra(Intent.EXTRA_EMAIL, new String[] { sendToAccount.second.name }); // TODO Open the chooser activity on work profile by default. // If we just use startActivityAsUser(), then the launched app couldn't read // attachments. // We probably need to change ChooserActivity to take an extra argument for the // default profile. } // Log what was sent to the intent Log.d(TAG, "share intent: EXTRA_SUBJECT=" + subject + ", EXTRA_TEXT=" + messageBody.length() + " chars, description=" + descriptionLength + " chars"); return intent; }
Shares the bugreport upon user's request by issuing a Intent.ACTION_SEND_MULTIPLE intent, but issuing a warning dialog the first time.
/** * Shares the bugreport upon user's request by issuing a {@link Intent#ACTION_SEND_MULTIPLE} * intent, but issuing a warning dialog the first time. */
private void shareBugreport(int id, BugreportInfo sharedInfo) { MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_SHARE); BugreportInfo info = getInfo(id); if (info == null) { // Service was terminated but notification persisted info = sharedInfo; Log.d(TAG, "shareBugreport(): no info for ID " + id + " on managed processes (" + mProcesses + "), using info from intent instead (" + info + ")"); } else { Log.v(TAG, "shareBugReport(): id " + id + " info = " + info); } addDetailsToZipFile(info); final Intent sendIntent = buildSendIntent(mContext, info); if (sendIntent == null) { Log.w(TAG, "Stopping progres on ID " + id + " because share intent could not be built"); stopProgress(id); return; } final Intent notifIntent; boolean useChooser = true; // Send through warning dialog by default if (getWarningState(mContext, STATE_UNKNOWN) != STATE_HIDE) { notifIntent = buildWarningIntent(mContext, sendIntent); // No need to show a chooser in this case. useChooser = false; } else { notifIntent = sendIntent; } notifIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // Send the share intent... if (useChooser) { sendShareIntent(mContext, notifIntent); } else { mContext.startActivity(notifIntent); } // ... and stop watching this process. stopProgress(id); } static void sendShareIntent(Context context, Intent intent) { final Intent chooserIntent = Intent.createChooser(intent, context.getResources().getText(R.string.bugreport_intent_chooser_title)); // Since we may be launched behind lockscreen, make sure that ChooserActivity doesn't finish // itself in onStop. chooserIntent.putExtra(ChooserActivity.EXTRA_PRIVATE_RETAIN_IN_ON_STOP, true); // Starting the activity from a service. chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(chooserIntent); }
Sends a notification indicating the bugreport has finished so use can share it.
/** * Sends a notification indicating the bugreport has finished so use can share it. */
private void sendBugreportNotification(BugreportInfo info, boolean takingScreenshot) { // Since adding the details can take a while, do it before notifying user. addDetailsToZipFile(info); final Intent shareIntent = new Intent(INTENT_BUGREPORT_SHARE); shareIntent.setClass(mContext, BugreportProgressService.class); shareIntent.setAction(INTENT_BUGREPORT_SHARE); shareIntent.putExtra(EXTRA_ID, info.id); shareIntent.putExtra(EXTRA_INFO, info); String content; content = takingScreenshot ? mContext.getString(R.string.bugreport_finished_pending_screenshot_text) : mContext.getString(R.string.bugreport_finished_text); final String title; if (TextUtils.isEmpty(info.title)) { title = mContext.getString(R.string.bugreport_finished_title, info.id); } else { title = info.title; if (!TextUtils.isEmpty(info.shareDescription)) { if(!takingScreenshot) content = info.shareDescription; } } final Notification.Builder builder = newBaseNotification(mContext) .setContentTitle(title) .setTicker(title) .setContentText(content) .setContentIntent(PendingIntent.getService(mContext, info.id, shareIntent, PendingIntent.FLAG_UPDATE_CURRENT)) .setDeleteIntent(newCancelIntent(mContext, info)); if (!TextUtils.isEmpty(info.name)) { builder.setSubText(info.name); } Log.v(TAG, "Sending 'Share' notification for ID " + info.id + ": " + title); NotificationManager.from(mContext).notify(info.id, builder.build()); }
Sends a notification indicating the bugreport is being updated so the user can wait until it finishes - at this point there is nothing to be done other than waiting, hence it has no pending action.
/** * Sends a notification indicating the bugreport is being updated so the user can wait until it * finishes - at this point there is nothing to be done other than waiting, hence it has no * pending action. */
private void sendBugreportBeingUpdatedNotification(Context context, int id) { final String title = context.getString(R.string.bugreport_updating_title); final Notification.Builder builder = newBaseNotification(context) .setContentTitle(title) .setTicker(title) .setContentText(context.getString(R.string.bugreport_updating_wait)); Log.v(TAG, "Sending 'Updating zip' notification for ID " + id + ": " + title); sendForegroundabledNotification(id, builder.build()); } private static Notification.Builder newBaseNotification(Context context) { if (sNotificationBundle.isEmpty()) { // Rename notifcations from "Shell" to "Android System" sNotificationBundle.putString(Notification.EXTRA_SUBSTITUTE_APP_NAME, context.getString(com.android.internal.R.string.android_system_label)); } return new Notification.Builder(context, NOTIFICATION_CHANNEL_ID) .addExtras(sNotificationBundle) .setSmallIcon( isTv(context) ? R.drawable.ic_bug_report_black_24dp : com.android.internal.R.drawable.stat_sys_adb) .setLocalOnly(true) .setColor(context.getColor( com.android.internal.R.color.system_notification_accent_color)) .extend(new Notification.TvExtender()); }
Sends a zipped bugreport notification.
/** * Sends a zipped bugreport notification. */
private void sendZippedBugreportNotification( final BugreportInfo info, final boolean takingScreenshot) { new AsyncTask<Void, Void, Void>() { @Override protected Void doInBackground(Void... params) { zipBugreport(info); sendBugreportNotification(info, takingScreenshot); return null; } }.execute(); }
Zips a bugreport file, returning the path to the new file (or to the original in case of failure).
/** * Zips a bugreport file, returning the path to the new file (or to the * original in case of failure). */
private static void zipBugreport(BugreportInfo info) { final String bugreportPath = info.bugreportFile.getAbsolutePath(); final String zippedPath = bugreportPath.replace(".txt", ".zip"); Log.v(TAG, "zipping " + bugreportPath + " as " + zippedPath); final File bugreportZippedFile = new File(zippedPath); try (InputStream is = new FileInputStream(info.bugreportFile); ZipOutputStream zos = new ZipOutputStream( new BufferedOutputStream(new FileOutputStream(bugreportZippedFile)))) { addEntry(zos, info.bugreportFile.getName(), is); // Delete old file final boolean deleted = info.bugreportFile.delete(); if (deleted) { Log.v(TAG, "deleted original bugreport (" + bugreportPath + ")"); } else { Log.e(TAG, "could not delete original bugreport (" + bugreportPath + ")"); } info.bugreportFile = bugreportZippedFile; } catch (IOException e) { Log.e(TAG, "exception zipping file " + zippedPath, e); } }
Adds the user-provided info into the bugreport zip file.

If user provided a title, it will be saved into a title.txt entry; similarly, the description will be saved on description.txt.

/** * Adds the user-provided info into the bugreport zip file. * <p> * If user provided a title, it will be saved into a {@code title.txt} entry; similarly, the * description will be saved on {@code description.txt}. */
private void addDetailsToZipFile(BugreportInfo info) { synchronized (mLock) { addDetailsToZipFileLocked(info); } } private void addDetailsToZipFileLocked(BugreportInfo info) { if (info.bugreportFile == null) { // One possible reason is a bug in the Parcelization code. Log.wtf(TAG, "addDetailsToZipFile(): no bugreportFile on " + info); return; } if (TextUtils.isEmpty(info.title) && TextUtils.isEmpty(info.description)) { Log.d(TAG, "Not touching zip file since neither title nor description are set"); return; } if (info.addedDetailsToZip || info.addingDetailsToZip) { Log.d(TAG, "Already added details to zip file for " + info); return; } info.addingDetailsToZip = true; // It's not possible to add a new entry into an existing file, so we need to create a new // zip, copy all entries, then rename it. sendBugreportBeingUpdatedNotification(mContext, info.id); // ...and that takes time final File dir = info.bugreportFile.getParentFile(); final File tmpZip = new File(dir, "tmp-" + info.bugreportFile.getName()); Log.d(TAG, "Writing temporary zip file (" + tmpZip + ") with title and/or description"); try (ZipFile oldZip = new ZipFile(info.bugreportFile); ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(tmpZip))) { // First copy contents from original zip. Enumeration<? extends ZipEntry> entries = oldZip.entries(); while (entries.hasMoreElements()) { final ZipEntry entry = entries.nextElement(); final String entryName = entry.getName(); if (!entry.isDirectory()) { addEntry(zos, entryName, entry.getTime(), oldZip.getInputStream(entry)); } else { Log.w(TAG, "skipping directory entry: " + entryName); } } // Then add the user-provided info. addEntry(zos, "title.txt", info.title); addEntry(zos, "description.txt", info.description); } catch (IOException e) { Log.e(TAG, "exception zipping file " + tmpZip, e); Toast.makeText(mContext, R.string.bugreport_add_details_to_zip_failed, Toast.LENGTH_LONG).show(); return; } finally { // Make sure it only tries to add details once, even it fails the first time. info.addedDetailsToZip = true; info.addingDetailsToZip = false; stopForegroundWhenDone(info.id); } if (!tmpZip.renameTo(info.bugreportFile)) { Log.e(TAG, "Could not rename " + tmpZip + " to " + info.bugreportFile); } } private static void addEntry(ZipOutputStream zos, String entry, String text) throws IOException { if (DEBUG) Log.v(TAG, "adding entry '" + entry + "': " + text); if (!TextUtils.isEmpty(text)) { addEntry(zos, entry, new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8))); } } private static void addEntry(ZipOutputStream zos, String entryName, InputStream is) throws IOException { addEntry(zos, entryName, System.currentTimeMillis(), is); } private static void addEntry(ZipOutputStream zos, String entryName, long timestamp, InputStream is) throws IOException { final ZipEntry entry = new ZipEntry(entryName); entry.setTime(timestamp); zos.putNextEntry(entry); final int totalBytes = Streams.copy(is, zos); if (DEBUG) Log.v(TAG, "size of '" + entryName + "' entry: " + totalBytes + " bytes"); zos.closeEntry(); }
Find the best matching Account based on build properties. If none found, returns the first account that looks like an email address.
/** * Find the best matching {@link Account} based on build properties. If none found, returns * the first account that looks like an email address. */
@VisibleForTesting static Pair<UserHandle, Account> findSendToAccount(Context context, String preferredDomain) { final UserManager um = context.getSystemService(UserManager.class); final AccountManager am = context.getSystemService(AccountManager.class); if (preferredDomain != null && !preferredDomain.startsWith("@")) { preferredDomain = "@" + preferredDomain; } Pair<UserHandle, Account> first = null; for (UserHandle user : um.getUserProfiles()) { final Account[] accounts; try { accounts = am.getAccountsAsUser(user.getIdentifier()); } catch (RuntimeException e) { Log.e(TAG, "Could not get accounts for preferred domain " + preferredDomain + " for user " + user, e); continue; } if (DEBUG) Log.d(TAG, "User: " + user + " Number of accounts: " + accounts.length); for (Account account : accounts) { if (Patterns.EMAIL_ADDRESS.matcher(account.name).matches()) { final Pair<UserHandle, Account> candidate = Pair.create(user, account); if (!TextUtils.isEmpty(preferredDomain)) { // if we have a preferred domain and it matches, return; otherwise keep // looking if (account.name.endsWith(preferredDomain)) { return candidate; } // if we don't have a preferred domain, just return since it looks like // an email address } else { return candidate; } if (first == null) { first = candidate; } } } } return first; } static Uri getUri(Context context, File file) { return file != null ? FileProvider.getUriForFile(context, AUTHORITY, file) : null; } static File getFileExtra(Intent intent, String key) { final String path = intent.getStringExtra(key); if (path != null) { return new File(path); } else { return null; } }
Dumps an intent, extracting the relevant extras.
/** * Dumps an intent, extracting the relevant extras. */
static String dumpIntent(Intent intent) { if (intent == null) { return "NO INTENT"; } String action = intent.getAction(); if (action == null) { // Happens when BugreportReceiver calls startService... action = "no action"; } final StringBuilder buffer = new StringBuilder(action).append(" extras: "); addExtra(buffer, intent, EXTRA_ID); addExtra(buffer, intent, EXTRA_PID); addExtra(buffer, intent, EXTRA_MAX); addExtra(buffer, intent, EXTRA_NAME); addExtra(buffer, intent, EXTRA_DESCRIPTION); addExtra(buffer, intent, EXTRA_BUGREPORT); addExtra(buffer, intent, EXTRA_SCREENSHOT); addExtra(buffer, intent, EXTRA_INFO); addExtra(buffer, intent, EXTRA_TITLE); if (intent.hasExtra(EXTRA_ORIGINAL_INTENT)) { buffer.append(SHORT_EXTRA_ORIGINAL_INTENT).append(": "); final Intent originalIntent = intent.getParcelableExtra(EXTRA_ORIGINAL_INTENT); buffer.append(dumpIntent(originalIntent)); } else { buffer.append("no ").append(SHORT_EXTRA_ORIGINAL_INTENT); } return buffer.toString(); } private static final String SHORT_EXTRA_ORIGINAL_INTENT = EXTRA_ORIGINAL_INTENT.substring(EXTRA_ORIGINAL_INTENT.lastIndexOf('.') + 1); private static void addExtra(StringBuilder buffer, Intent intent, String name) { final String shortName = name.substring(name.lastIndexOf('.') + 1); if (intent.hasExtra(name)) { buffer.append(shortName).append('=').append(intent.getExtra(name)); } else { buffer.append("no ").append(shortName); } buffer.append(", "); } private static boolean setSystemProperty(String key, String value) { try { if (DEBUG) Log.v(TAG, "Setting system property " + key + " to " + value); SystemProperties.set(key, value); } catch (IllegalArgumentException e) { Log.e(TAG, "Could not set property " + key + " to " + value, e); return false; } return true; }
Updates the system property used by dumpstate to rename the final bugreport files.
/** * Updates the system property used by {@code dumpstate} to rename the final bugreport files. */
private boolean setBugreportNameProperty(int pid, String name) { Log.d(TAG, "Updating bugreport name to " + name); final String key = DUMPSTATE_PREFIX + pid + NAME_SUFFIX; return setSystemProperty(key, name); }
Updates the user-provided details of a bugreport.
/** * Updates the user-provided details of a bugreport. */
private void updateBugreportInfo(int id, String name, String title, String description) { final BugreportInfo info = getInfo(id); if (info == null) { return; } if (title != null && !title.equals(info.title)) { Log.d(TAG, "updating bugreport title: " + title); MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_TITLE_CHANGED); } info.title = title; if (description != null && !description.equals(info.description)) { Log.d(TAG, "updating bugreport description: " + description.length() + " chars"); MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_DESCRIPTION_CHANGED); } info.description = description; if (name != null && !name.equals(info.name)) { Log.d(TAG, "updating bugreport name: " + name); MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_NAME_CHANGED); info.name = name; updateProgress(info); } } private void collapseNotificationBar() { sendBroadcast(new Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)); } private static Looper newLooper(String name) { final HandlerThread thread = new HandlerThread(name, THREAD_PRIORITY_BACKGROUND); thread.start(); return thread.getLooper(); }
Takes a screenshot and save it to the given location.
/** * Takes a screenshot and save it to the given location. */
private static boolean takeScreenshot(Context context, String path) { final Bitmap bitmap = Screenshooter.takeScreenshot(); if (bitmap == null) { return false; } try (final FileOutputStream fos = new FileOutputStream(path)) { if (bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)) { ((Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE)).vibrate(150); return true; } else { Log.e(TAG, "Failed to save screenshot on " + path); } } catch (IOException e ) { Log.e(TAG, "Failed to save screenshot on " + path, e); return false; } finally { bitmap.recycle(); } return false; } private static boolean isTv(Context context) { return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK); }
Checks whether a character is valid on bugreport names.
/** * Checks whether a character is valid on bugreport names. */
@VisibleForTesting static boolean isValid(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-'; }
Helper class encapsulating the UI elements and logic used to display a dialog where user can change the details of a bugreport.
/** * Helper class encapsulating the UI elements and logic used to display a dialog where user * can change the details of a bugreport. */
private final class BugreportInfoDialog { private EditText mInfoName; private EditText mInfoTitle; private EditText mInfoDescription; private AlertDialog mDialog; private Button mOkButton; private int mId; private int mPid;
Last "committed" value of the bugreport name.

Once initially set, it's only updated when user clicks the OK button.

/** * Last "committed" value of the bugreport name. * <p> * Once initially set, it's only updated when user clicks the OK button. */
private String mSavedName;
Last value of the bugreport name as entered by the user.

Every time it's changed the equivalent system property is changed as well, but if the user clicks CANCEL, the old value (stored on mSavedName is restored.

This logic handles the corner-case scenario where dumpstate finishes after the user changed the name but didn't clicked OK yet (for example, because the user is typing the description). The only drawback is that if the user changes the name while dumpstate is running but clicks CANCEL after it finishes, then the final name will be the one that has been canceled. But when dumpstate finishes the {code name} UI is disabled and the old name restored anyways, so the user will be "alerted" of such drawback.

/** * Last value of the bugreport name as entered by the user. * <p> * Every time it's changed the equivalent system property is changed as well, but if the * user clicks CANCEL, the old value (stored on {@code mSavedName} is restored. * <p> * This logic handles the corner-case scenario where {@code dumpstate} finishes after the * user changed the name but didn't clicked OK yet (for example, because the user is typing * the description). The only drawback is that if the user changes the name while * {@code dumpstate} is running but clicks CANCEL after it finishes, then the final name * will be the one that has been canceled. But when {@code dumpstate} finishes the {code * name} UI is disabled and the old name restored anyways, so the user will be "alerted" of * such drawback. */
private String mTempName;
Sets its internal state and displays the dialog.
/** * Sets its internal state and displays the dialog. */
@MainThread void initialize(final Context context, BugreportInfo info) { final String dialogTitle = context.getString(R.string.bugreport_info_dialog_title, info.id); // First initializes singleton. if (mDialog == null) { @SuppressLint("InflateParams") // It's ok pass null ViewRoot on AlertDialogs. final View view = View.inflate(context, R.layout.dialog_bugreport_info, null); mInfoName = (EditText) view.findViewById(R.id.name); mInfoTitle = (EditText) view.findViewById(R.id.title); mInfoDescription = (EditText) view.findViewById(R.id.description); mInfoName.setOnFocusChangeListener(new OnFocusChangeListener() { @Override public void onFocusChange(View v, boolean hasFocus) { if (hasFocus) { return; } sanitizeName(); } }); mDialog = new AlertDialog.Builder(context) .setView(view) .setTitle(dialogTitle) .setCancelable(true) .setPositiveButton(context.getString(R.string.save), null) .setNegativeButton(context.getString(com.android.internal.R.string.cancel), new DialogInterface.OnClickListener() { @Override public void onClick(DialogInterface dialog, int id) { MetricsLogger.action(context, MetricsEvent.ACTION_BUGREPORT_DETAILS_CANCELED); if (!mTempName.equals(mSavedName)) { // Must restore dumpstate's name since it was changed // before user clicked OK. setBugreportNameProperty(mPid, mSavedName); } } }) .create(); mDialog.getWindow().setAttributes( new WindowManager.LayoutParams( WindowManager.LayoutParams.TYPE_SYSTEM_DIALOG)); } else { // Re-use view, but reset fields first. mDialog.setTitle(dialogTitle); mInfoName.setText(null); mInfoName.setEnabled(true); mInfoTitle.setText(null); mInfoDescription.setText(null); } // Then set fields. mSavedName = mTempName = info.name; mId = info.id; mPid = info.pid; if (!TextUtils.isEmpty(info.name)) { mInfoName.setText(info.name); } if (!TextUtils.isEmpty(info.title)) { mInfoTitle.setText(info.title); } if (!TextUtils.isEmpty(info.description)) { mInfoDescription.setText(info.description); } // And finally display it. mDialog.show(); // TODO: in a traditional AlertDialog, when the positive button is clicked the // dialog is always closed, but we need to validate the name first, so we need to // get a reference to it, which is only available after it's displayed. // It would be cleaner to use a regular dialog instead, but let's keep this // workaround for now and change it later, when we add another button to take // extra screenshots. if (mOkButton == null) { mOkButton = mDialog.getButton(DialogInterface.BUTTON_POSITIVE); mOkButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { MetricsLogger.action(context, MetricsEvent.ACTION_BUGREPORT_DETAILS_SAVED); sanitizeName(); final String name = mInfoName.getText().toString(); final String title = mInfoTitle.getText().toString(); final String description = mInfoDescription.getText().toString(); updateBugreportInfo(mId, name, title, description); mDialog.dismiss(); } }); } }
Sanitizes the user-provided value for the name field, automatically replacing invalid characters if necessary.
/** * Sanitizes the user-provided value for the {@code name} field, automatically replacing * invalid characters if necessary. */
private void sanitizeName() { String name = mInfoName.getText().toString(); if (name.equals(mTempName)) { if (DEBUG) Log.v(TAG, "name didn't change, no need to sanitize: " + name); return; } final StringBuilder safeName = new StringBuilder(name.length()); boolean changed = false; for (int i = 0; i < name.length(); i++) { final char c = name.charAt(i); if (isValid(c)) { safeName.append(c); } else { changed = true; safeName.append('_'); } } if (changed) { Log.v(TAG, "changed invalid name '" + name + "' to '" + safeName + "'"); name = safeName.toString(); mInfoName.setText(name); } mTempName = name; // Must update system property for the cases where dumpstate finishes // while the user is still entering other fields (like title or // description) setBugreportNameProperty(mPid, name); }
Notifies the dialog that the bugreport has finished so it disables the name field.

Once the bugreport is finished dumpstate has already generated the final files, so changing the name would have no effect.

/** * Notifies the dialog that the bugreport has finished so it disables the {@code name} * field. * <p>Once the bugreport is finished dumpstate has already generated the final files, so * changing the name would have no effect. */
void onBugreportFinished() { if (mInfoName != null) { mInfoName.setEnabled(false); mInfoName.setText(mSavedName); } } void cancel() { if (mDialog != null) { mDialog.cancel(); } } }
Information about a bugreport process while its in progress.
/** * Information about a bugreport process while its in progress. */
private static final class BugreportInfo implements Parcelable { private final Context context;
Sequential, user-friendly id used to identify the bugreport.
/** * Sequential, user-friendly id used to identify the bugreport. */
final int id;
pid of the dumpstate process generating the bugreport.
/** * {@code pid} of the {@code dumpstate} process generating the bugreport. */
final int pid;
Name of the bugreport, will be used to rename the final files.

Initial value is the bugreport filename reported by dumpstate, but user can change it later to a more meaningful name.

/** * Name of the bugreport, will be used to rename the final files. * <p> * Initial value is the bugreport filename reported by {@code dumpstate}, but user can * change it later to a more meaningful name. */
String name;
User-provided, one-line summary of the bug; when set, will be used as the subject of the Intent.ACTION_SEND_MULTIPLE intent.
/** * User-provided, one-line summary of the bug; when set, will be used as the subject * of the {@link Intent#ACTION_SEND_MULTIPLE} intent. */
String title;
User-provided, detailed description of the bugreport; when set, will be added to the body of the Intent.ACTION_SEND_MULTIPLE intent.
/** * User-provided, detailed description of the bugreport; when set, will be added to the body * of the {@link Intent#ACTION_SEND_MULTIPLE} intent. */
String description;
Maximum progress of the bugreport generation as displayed by the UI.
/** * Maximum progress of the bugreport generation as displayed by the UI. */
int max;
Current progress of the bugreport generation as displayed by the UI.
/** * Current progress of the bugreport generation as displayed by the UI. */
int progress;
Maximum progress of the bugreport generation as reported by dumpstate.
/** * Maximum progress of the bugreport generation as reported by dumpstate. */
int realMax;
Current progress of the bugreport generation as reported by dumpstate.
/** * Current progress of the bugreport generation as reported by dumpstate. */
int realProgress;
Time of the last progress update.
/** * Time of the last progress update. */
long lastUpdate = System.currentTimeMillis();
Time of the last progress update when Parcel was created.
/** * Time of the last progress update when Parcel was created. */
String formattedLastUpdate;
Path of the main bugreport file.
/** * Path of the main bugreport file. */
File bugreportFile;
Path of the screenshot files.
/** * Path of the screenshot files. */
List<File> screenshotFiles = new ArrayList<>(1);
Whether dumpstate sent an intent informing it has finished.
/** * Whether dumpstate sent an intent informing it has finished. */
boolean finished;
Whether the details entries have been added to the bugreport yet.
/** * Whether the details entries have been added to the bugreport yet. */
boolean addingDetailsToZip; boolean addedDetailsToZip;
Internal counter used to name screenshot files.
/** * Internal counter used to name screenshot files. */
int screenshotCounter;
Descriptive text that will be shown to the user in the notification message.
/** * Descriptive text that will be shown to the user in the notification message. */
String shareDescription;
Constructor for tracked bugreports - typically called upon receiving BUGREPORT_STARTED.
/** * Constructor for tracked bugreports - typically called upon receiving BUGREPORT_STARTED. */
BugreportInfo(Context context, int id, int pid, String name, int max) { this.context = context; this.id = id; this.pid = pid; this.name = name; this.max = this.realMax = max; }
Constructor for untracked bugreports - typically called upon receiving BUGREPORT_FINISHED without a previous call to BUGREPORT_STARTED.
/** * Constructor for untracked bugreports - typically called upon receiving BUGREPORT_FINISHED * without a previous call to BUGREPORT_STARTED. */
BugreportInfo(Context context, int id) { this(context, id, id, null, 0); this.finished = true; }
Gets the name for next screenshot file.
/** * Gets the name for next screenshot file. */
String getPathNextScreenshot() { screenshotCounter ++; return "screenshot-" + pid + "-" + screenshotCounter + ".png"; }
Saves the location of a taken screenshot so it can be sent out at the end.
/** * Saves the location of a taken screenshot so it can be sent out at the end. */
void addScreenshot(File screenshot) { screenshotFiles.add(screenshot); }
Rename all screenshots files so that they contain the user-generated name instead of pid.
/** * Rename all screenshots files so that they contain the user-generated name instead of pid. */
void renameScreenshots(File screenshotDir) { if (TextUtils.isEmpty(name)) { return; } final List<File> renamedFiles = new ArrayList<>(screenshotFiles.size()); for (File oldFile : screenshotFiles) { final String oldName = oldFile.getName(); final String newName = oldName.replaceFirst(Integer.toString(pid), name); final File newFile; if (!newName.equals(oldName)) { final File renamedFile = new File(screenshotDir, newName); Log.d(TAG, "Renaming screenshot file " + oldFile + " to " + renamedFile); newFile = oldFile.renameTo(renamedFile) ? renamedFile : oldFile; } else { Log.w(TAG, "Name didn't change: " + oldName); // Shouldn't happen. newFile = oldFile; } renamedFiles.add(newFile); } screenshotFiles = renamedFiles; } String getFormattedLastUpdate() { if (context == null) { // Restored from Parcel return formattedLastUpdate == null ? Long.toString(lastUpdate) : formattedLastUpdate; } return DateUtils.formatDateTime(context, lastUpdate, DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME); } @Override public String toString() { final float percent = ((float) progress * 100 / max); final float realPercent = ((float) realProgress * 100 / realMax); final StringBuilder builder = new StringBuilder() .append("\tid: ").append(id) .append(", pid: ").append(pid) .append(", name: ").append(name) .append(", finished: ").append(finished) .append("\n\ttitle: ").append(title) .append("\n\tdescription: "); if (description == null) { builder.append("null"); } else { if (TextUtils.getTrimmedLength(description) == 0) { builder.append("empty "); } builder.append("(").append(description.length()).append(" chars)"); } return builder .append("\n\tfile: ").append(bugreportFile) .append("\n\tscreenshots: ").append(screenshotFiles) .append("\n\tprogress: ").append(progress).append("/").append(max) .append(" (").append(percent).append(")") .append("\n\treal progress: ").append(realProgress).append("/").append(realMax) .append(" (").append(realPercent).append(")") .append("\n\tlast_update: ").append(getFormattedLastUpdate()) .append("\n\taddingDetailsToZip: ").append(addingDetailsToZip) .append(" addedDetailsToZip: ").append(addedDetailsToZip) .append("\n\tshareDescription: ").append(shareDescription) .toString(); } // Parcelable contract protected BugreportInfo(Parcel in) { context = null; id = in.readInt(); pid = in.readInt(); name = in.readString(); title = in.readString(); description = in.readString(); max = in.readInt(); progress = in.readInt(); realMax = in.readInt(); realProgress = in.readInt(); lastUpdate = in.readLong(); formattedLastUpdate = in.readString(); bugreportFile = readFile(in); int screenshotSize = in.readInt(); for (int i = 1; i <= screenshotSize; i++) { screenshotFiles.add(readFile(in)); } finished = in.readInt() == 1; screenshotCounter = in.readInt(); shareDescription = in.readString(); } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeInt(id); dest.writeInt(pid); dest.writeString(name); dest.writeString(title); dest.writeString(description); dest.writeInt(max); dest.writeInt(progress); dest.writeInt(realMax); dest.writeInt(realProgress); dest.writeLong(lastUpdate); dest.writeString(getFormattedLastUpdate()); writeFile(dest, bugreportFile); dest.writeInt(screenshotFiles.size()); for (File screenshotFile : screenshotFiles) { writeFile(dest, screenshotFile); } dest.writeInt(finished ? 1 : 0); dest.writeInt(screenshotCounter); dest.writeString(shareDescription); } @Override public int describeContents() { return 0; } private void writeFile(Parcel dest, File file) { dest.writeString(file == null ? null : file.getPath()); } private File readFile(Parcel in) { final String path = in.readString(); return path == null ? null : new File(path); } @SuppressWarnings("unused") public static final Parcelable.Creator<BugreportInfo> CREATOR = new Parcelable.Creator<BugreportInfo>() { @Override public BugreportInfo createFromParcel(Parcel source) { return new BugreportInfo(source); } @Override public BugreportInfo[] newArray(int size) { return new BugreportInfo[size]; } }; } private final class DumpstateListener extends IDumpstateListener.Stub implements DeathRecipient { private final BugreportInfo info; private IDumpstateToken token; DumpstateListener(BugreportInfo info) { this.info = info; }
Connects to the dumpstate binder to receive updates.
/** * Connects to the {@code dumpstate} binder to receive updates. */
boolean connect() { if (token != null) { Log.d(TAG, "connect(): " + info.id + " already connected"); return true; } final IBinder service = ServiceManager.getService("dumpstate"); if (service == null) { Log.d(TAG, "dumpstate service not bound yet"); return true; } final IDumpstate dumpstate = IDumpstate.Stub.asInterface(service); try { token = dumpstate.setListener("Shell", this, /* perSectionDetails= */ false); if (token != null) { token.asBinder().linkToDeath(this, 0); } } catch (Exception e) { Log.e(TAG, "Could not set dumpstate listener: " + e); } return token != null; } @Override public void binderDied() { if (!info.finished) { // TODO: linkToDeath() might be called BEFORE Shell received the // BUGREPORT_FINISHED broadcast, in which case the statements below // spam logcat (but are harmless). // The right, long-term solution is to provide an onFinished() callback // on IDumpstateListener and call it instead of using a broadcast. Log.w(TAG, "Dumpstate process died:\n" + info); stopProgress(info.id); } token.asBinder().unlinkToDeath(this, 0); } @Override public void onProgressUpdated(int progress) throws RemoteException { /* * Checks whether the progress changed in a way that should be displayed to the user: * - info.progress / info.max represents the displayed progress * - info.realProgress / info.realMax represents the real progress * - since the real progress can decrease, the displayed progress is only updated if it * increases * - the displayed progress is capped at a maximum (like 99%) */ info.realProgress = progress; final int oldPercentage = (CAPPED_MAX * info.progress) / info.max; int newPercentage = (CAPPED_MAX * info.realProgress) / info.realMax; int max = info.realMax; if (newPercentage > CAPPED_PROGRESS) { progress = newPercentage = CAPPED_PROGRESS; max = CAPPED_MAX; } if (newPercentage > oldPercentage) { if (DEBUG) { if (progress != info.progress) { Log.v(TAG, "Updating progress for PID " + info.pid + "(id: " + info.id + ") from " + info.progress + " to " + progress); } if (max != info.max) { Log.v(TAG, "Updating max progress for PID " + info.pid + "(id: " + info.id + ") from " + info.max + " to " + max); } } info.progress = progress; info.max = max; info.lastUpdate = System.currentTimeMillis(); updateProgress(info); } } @Override public void onMaxProgressUpdated(int maxProgress) throws RemoteException { Log.d(TAG, "onMaxProgressUpdated: " + maxProgress); info.realMax = maxProgress; } @Override public void onSectionComplete(String title, int status, int size, int durationMs) throws RemoteException { if (DEBUG) { Log.v(TAG, "Title: " + title + " Status: " + status + " Size: " + size + " Duration: " + durationMs + "ms"); } } public void dump(String prefix, PrintWriter pw) { pw.print(prefix); pw.print("token: "); pw.println(token); } } }