/* * 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.content.pm.PackageManager.FEATURE_LEANBACK; import static android.content.pm.PackageManager.FEATURE_TELEVISION; 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 android.accounts.Account; import android.accounts.AccountManager; import android.annotation.MainThread; import android.annotation.Nullable; import android.annotation.SuppressLint; import android.app.ActivityThread; 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.app.admin.DevicePolicyManager; 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.BugreportManager; import android.os.BugreportManager.BugreportCallback; import android.os.BugreportManager.BugreportCallback.BugreportErrorCode; import android.os.BugreportParams; import android.os.Bundle; import android.os.FileUtils; import android.os.Handler; import android.os.HandlerThread; import android.os.IBinder; import android.os.Looper; import android.os.Message; import android.os.Parcel; import android.os.ParcelFileDescriptor; import android.os.Parcelable; import android.os.ServiceManager; import android.os.SystemProperties; import android.os.UserHandle; import android.os.UserManager; import android.os.Vibrator; 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.ContextThemeWrapper; import android.view.IWindowManager; import android.view.View; import android.view.WindowManager; import android.widget.Button; import android.widget.EditText; import android.widget.Toast; import androidx.core.content.FileProvider; import com.android.internal.annotations.GuardedBy; 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.google.android.collect.Lists; import libcore.io.Streams; import java.io.BufferedOutputStream; import java.io.ByteArrayInputStream; import java.io.File; import java.io.FileDescriptor; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.PrintWriter; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.NumberFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Date; import java.util.Enumeration; import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; /** * Service used to trigger system bugreports. *

* The workflow uses Bugreport API({@code BugreportManager}) and is as follows: *

    *
  1. System apps like Settings or SysUI broadcasts {@code BUGREPORT_REQUESTED}. *
  2. {@link BugreportRequestedReceiver} receives the intent and delegates it to this service. *
  3. This service calls startBugreport() and passes in local file descriptors to receive * bugreport artifacts. *
*/ public class BugreportProgressService extends Service { private static final String TAG = "BugreportProgressService"; private static final boolean DEBUG = false; private Intent startSelfIntent; private static final String AUTHORITY = "com.android.shell"; // External intent used to trigger bugreport API. static final String INTENT_BUGREPORT_REQUESTED = "com.android.internal.intent.action.BUGREPORT_REQUESTED"; // Intent sent to notify external apps that bugreport finished static final String INTENT_BUGREPORT_FINISHED = "com.android.internal.intent.action.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_BUGREPORT_TYPE = "android.intent.extra.BUGREPORT_TYPE"; static final String EXTRA_SCREENSHOT = "android.intent.extra.SCREENSHOT"; static final String EXTRA_ID = "android.intent.extra.ID"; 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 in %. private static final int CAPPED_PROGRESS = 99; /** 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. */ static final int SCREENSHOT_DELAY_SECONDS = 3; /** System property where dumpstate stores last triggered bugreport id */ private static final String PROPERTY_LAST_ID = "dumpstate.last_id"; 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. */ private static final String BUGREPORT_DIR = "bugreports"; private static final String NOTIFICATION_CHANNEL_ID = "bugreports"; /** * Always keep the newest 8 bugreport files. */ private static final int MIN_KEEP_COUNT = 8; /** * Always keep bugreports taken in the last week. */ private static final long MIN_KEEP_AGE = DateUtils.WEEK_IN_MILLIS; private static final String BUGREPORT_MIMETYPE = "application/vnd.android.bugreport"; /** Always keep just the last 3 remote bugreport's files around. */ private static final int REMOTE_BUGREPORT_FILES_AMOUNT = 3; /** Always keep remote bugreport files created in the last day. */ private static final long REMOTE_MIN_KEEP_AGE = DateUtils.DAY_IN_MILLIS; private final Object mLock = new Object(); /** Managed bugreport info (keyed by id) */ @GuardedBy("mLock") private final SparseArray mBugreportInfos = new SparseArray<>(); private Context mContext; private Handler mMainThreadHandler; private ServiceHandler mServiceHandler; private ScreenshotHandler mScreenshotHandler; private final BugreportInfoDialog mInfoDialog = new BugreportInfoDialog(); private File mBugreportsDir; private BugreportManager mBugreportManager; /** * 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. */ private boolean mTakingScreenshot; @GuardedBy("sNotificationBundle") private static final Bundle sNotificationBundle = new Bundle(); private boolean mIsWatch; private boolean mIsTv; @Override public void onCreate() { mContext = getApplicationContext(); mMainThreadHandler = new Handler(Looper.getMainLooper()); mServiceHandler = new ServiceHandler("BugreportProgressServiceMainThread"); mScreenshotHandler = new ScreenshotHandler("BugreportProgressServiceScreenshotThread"); startSelfIntent = new Intent(this, this.getClass()); mBugreportsDir = new File(getFilesDir(), BUGREPORT_DIR); if (!mBugreportsDir.exists()) { Log.i(TAG, "Creating directory " + mBugreportsDir + " to store bugreports and screenshots"); if (!mBugreportsDir.mkdir()) { Log.w(TAG, "Could not create directory " + mBugreportsDir); } } final Configuration conf = mContext.getResources().getConfiguration(); mIsWatch = (conf.uiMode & Configuration.UI_MODE_TYPE_MASK) == Configuration.UI_MODE_TYPE_WATCH; PackageManager packageManager = getPackageManager(); mIsTv = packageManager.hasSystemFeature(FEATURE_LEANBACK) || packageManager.hasSystemFeature(FEATURE_TELEVISION); 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) { if (!intent.hasExtra(EXTRA_ORIGINAL_INTENT) && !intent.hasExtra(EXTRA_ID)) { return START_NOT_STICKY; } // 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) { synchronized (mLock) { final int size = mBugreportInfos.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(getInfoLocked(mBugreportInfos.keyAt(i))); } } } private static String getFileName(BugreportInfo info, String suffix) { return String.format("%s-%s%s", info.baseName, info.getName(), suffix); } private final class BugreportCallbackImpl extends BugreportCallback { @GuardedBy("mLock") private final BugreportInfo mInfo; BugreportCallbackImpl(BugreportInfo info) { mInfo = info; } @Override public void onProgress(float progress) { synchronized (mLock) { checkProgressUpdatedLocked(mInfo, (int) progress); } } /** * Logs errors and stops the service on which this bugreport was running. * Also stops progress notification (if any). */ @Override public void onError(@BugreportErrorCode int errorCode) { synchronized (mLock) { stopProgressLocked(mInfo.id); mInfo.deleteEmptyFiles(); } Log.e(TAG, "Bugreport API callback onError() errorCode = " + errorCode); return; } @Override public void onFinished() { mInfo.renameBugreportFile(); mInfo.renameScreenshots(); synchronized (mLock) { sendBugreportFinishedBroadcastLocked(); } } /** * Reads bugreport id and links it to the bugreport info to track a bugreport that is in * process. id is incremented in the dumpstate code. * We do not track a bugreport if there is already a bugreport with the same id being * tracked. */ @GuardedBy("mLock") private void trackInfoWithIdLocked() { final int id = SystemProperties.getInt(PROPERTY_LAST_ID, 1); if (mBugreportInfos.get(id) == null) { mInfo.id = id; mBugreportInfos.put(mInfo.id, mInfo); } return; } @GuardedBy("mLock") private void sendBugreportFinishedBroadcastLocked() { final String bugreportFilePath = mInfo.bugreportFile.getAbsolutePath(); if (mInfo.bugreportFile.length() == 0) { Log.e(TAG, "Bugreport file empty. File path = " + bugreportFilePath); return; } if (mInfo.type == BugreportParams.BUGREPORT_MODE_REMOTE) { sendRemoteBugreportFinishedBroadcast(mContext, bugreportFilePath, mInfo.bugreportFile); } else { cleanupOldFiles(MIN_KEEP_COUNT, MIN_KEEP_AGE, mBugreportsDir); final Intent intent = new Intent(INTENT_BUGREPORT_FINISHED); intent.putExtra(EXTRA_BUGREPORT, bugreportFilePath); intent.putExtra(EXTRA_SCREENSHOT, getScreenshotForIntent(mInfo)); mContext.sendBroadcast(intent, android.Manifest.permission.DUMP); onBugreportFinished(mInfo); } } } private static void sendRemoteBugreportFinishedBroadcast(Context context, String bugreportFileName, File bugreportFile) { cleanupOldFiles(REMOTE_BUGREPORT_FILES_AMOUNT, REMOTE_MIN_KEEP_AGE, bugreportFile.getParentFile()); final Intent intent = new Intent(DevicePolicyManager.ACTION_REMOTE_BUGREPORT_DISPATCH); final Uri bugreportUri = getUri(context, bugreportFile); final String bugreportHash = generateFileHash(bugreportFileName); if (bugreportHash == null) { Log.e(TAG, "Error generating file hash for remote bugreport"); } intent.setDataAndType(bugreportUri, BUGREPORT_MIMETYPE); intent.putExtra(DevicePolicyManager.EXTRA_REMOTE_BUGREPORT_HASH, bugreportHash); intent.putExtra(EXTRA_BUGREPORT, bugreportFileName); context.sendBroadcastAsUser(intent, UserHandle.SYSTEM, android.Manifest.permission.DUMP); } /** * Checks if screenshot array is non-empty and returns the first screenshot's path. The first * screenshot is the default screenshot for the bugreport types that take it. */ private static String getScreenshotForIntent(BugreportInfo info) { if (!info.screenshotFiles.isEmpty()) { final File screenshotFile = info.screenshotFiles.get(0); final String screenshotFilePath = screenshotFile.getAbsolutePath(); return screenshotFilePath; } return null; } private static String generateFileHash(String fileName) { String fileHash = null; try { MessageDigest md = MessageDigest.getInstance("SHA-256"); FileInputStream input = new FileInputStream(new File(fileName)); byte[] buffer = new byte[65536]; int size; while ((size = input.read(buffer)) > 0) { md.update(buffer, 0, size); } input.close(); byte[] hashBytes = md.digest(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < hashBytes.length; i++) { sb.append(String.format("%02x", hashBytes[i])); } fileHash = sb.toString(); } catch (IOException | NoSuchAlgorithmException e) { Log.e(TAG, "generating file hash for bugreport file failed " + fileName, e); } return fileHash; } static void cleanupOldFiles(final int minCount, final long minAge, File bugreportsDir) { new AsyncTask() { @Override protected Void doInBackground(Void... params) { try { FileUtils.deleteOlderFiles(bugreportsDir, minCount, minAge); } catch (RuntimeException e) { Log.e(TAG, "RuntimeException deleting old files", e); } return null; } }.execute(); } /** * 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 BugreportRequestedReceiver, // which delegated to the service. intent = (Intent) parcel; } else { intent = (Intent) msg.obj; } final String action = intent.getAction(); final int id = intent.getIntExtra(EXTRA_ID, 0); final String name = intent.getStringExtra(EXTRA_NAME); if (DEBUG) Log.v(TAG, "action: " + action + ", name: " + name + ", id: " + id); switch (action) { case INTENT_BUGREPORT_REQUESTED: startBugreportAPI(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. */ 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); } } @GuardedBy("mLock") private BugreportInfo getInfoLocked(int id) { final BugreportInfo bugreportInfo = mBugreportInfos.get(id); if (bugreportInfo == null) { Log.w(TAG, "Not monitoring bugreports with ID " + id); return null; } return bugreportInfo; } private String getBugreportBaseName(@BugreportParams.BugreportMode int type) { String buildId = SystemProperties.get("ro.build.id", "UNKNOWN_BUILD"); String deviceName = SystemProperties.get("ro.product.name", "UNKNOWN_DEVICE"); String typeSuffix = null; if (type == BugreportParams.BUGREPORT_MODE_WIFI) { typeSuffix = "wifi"; } else if (type == BugreportParams.BUGREPORT_MODE_TELEPHONY) { typeSuffix = "telephony"; } else { return String.format("bugreport-%s-%s", deviceName, buildId); } return String.format("bugreport-%s-%s-%s", deviceName, buildId, typeSuffix); } private void startBugreportAPI(Intent intent) { String shareTitle = intent.getStringExtra(EXTRA_TITLE); String shareDescription = intent.getStringExtra(EXTRA_DESCRIPTION); int bugreportType = intent.getIntExtra(EXTRA_BUGREPORT_TYPE, BugreportParams.BUGREPORT_MODE_INTERACTIVE); String baseName = getBugreportBaseName(bugreportType); String name = new SimpleDateFormat("yyyy-MM-dd-HH-mm-ss").format(new Date()); BugreportInfo info = new BugreportInfo(mContext, baseName, name, shareTitle, shareDescription, bugreportType, mBugreportsDir); ParcelFileDescriptor bugreportFd = info.getBugreportFd(); if (bugreportFd == null) { Log.e(TAG, "Failed to start bugreport generation as " + " bugreport parcel file descriptor is null."); return; } ParcelFileDescriptor screenshotFd = null; if (isDefaultScreenshotRequired(bugreportType, /* hasScreenshotButton= */ !mIsTv)) { screenshotFd = info.getDefaultScreenshotFd(); if (screenshotFd == null) { Log.e(TAG, "Failed to start bugreport generation as" + " screenshot parcel file descriptor is null. Deleting bugreport file"); FileUtils.closeQuietly(bugreportFd); info.bugreportFile.delete(); return; } } mBugreportManager = (BugreportManager) mContext.getSystemService( Context.BUGREPORT_SERVICE); final Executor executor = ActivityThread.currentActivityThread().getExecutor(); Log.i(TAG, "bugreport type = " + bugreportType + " bugreport file fd: " + bugreportFd + " screenshot file fd: " + screenshotFd); BugreportCallbackImpl bugreportCallback = new BugreportCallbackImpl(info); try { synchronized (mLock) { mBugreportManager.startBugreport(bugreportFd, screenshotFd, new BugreportParams(bugreportType), executor, bugreportCallback); bugreportCallback.trackInfoWithIdLocked(); } } catch (RuntimeException e) { Log.i(TAG, "Error in generating bugreports: ", e); // The binder call didn't go through successfully, so need to close the fds. // If the calls went through API takes ownership. FileUtils.closeQuietly(bugreportFd); if (screenshotFd != null) { FileUtils.closeQuietly(screenshotFd); } } } private static boolean isDefaultScreenshotRequired( @BugreportParams.BugreportMode int bugreportType, boolean hasScreenshotButton) { // Modify dumpstate#SetOptionsFromMode as well for default system screenshots. // We override dumpstate for interactive bugreports with a screenshot button. return (bugreportType == BugreportParams.BUGREPORT_MODE_INTERACTIVE && !hasScreenshotButton) || bugreportType == BugreportParams.BUGREPORT_MODE_FULL || bugreportType == BugreportParams.BUGREPORT_MODE_WEAR; } private static ParcelFileDescriptor getFd(File file) { try { return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_WRITE_ONLY | ParcelFileDescriptor.MODE_APPEND); } catch (FileNotFoundException e) { Log.i(TAG, "Error in generating bugreports: ", e); } return null; } private static void createReadWriteFile(File file) { try { if (!file.exists()) { file.createNewFile(); file.setReadable(true, true); file.setWritable(true, true); } } catch (IOException e) { Log.e(TAG, "Error in creating bugreport file: ", e); } } /** * Updates the system notification for a given bugreport. */ private void updateProgress(BugreportInfo info) { if (info.progress.intValue() < 0) { Log.e(TAG, "Invalid progress values for " + info); return; } if (info.finished.get()) { 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.intValue() / 100); 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.intValue() / 100); title = title + "\n" + watchPercentageText; } final String name = info.getName() != null ? info.getName() : mContext.getString(R.string.bugreport_unnamed); final Notification.Builder builder = newBaseNotification(mContext) .setContentTitle(title) .setTicker(title) .setContentText(name) .setProgress(100 /* max value of progress percentage */, info.progress.intValue(), false) .setOngoing(true); // Wear and ATV bugreport doesn't need the bug info dialog, screenshot and cancel action. if (!(mIsWatch || mIsTv)) { 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.intValue(); if ((progress == 0) || (progress >= 100) || ((progress / LOG_PROGRESS_STEP) != (info.lastProgress.intValue() / LOG_PROGRESS_STEP))) { Log.d(TAG, "Progress #" + info.id + ": " + percentageText); } info.lastProgress.set(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); // Explicitly starting the service so that stopForeground() does not crash // Workaround for b/140997620 startForegroundService(startSelfIntent); startForeground(mForegroundId, notification); } } /** * 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. */ @GuardedBy("mLock") private void stopProgressLocked(int id) { if (mBugreportInfos.indexOfKey(id) < 0) { Log.w(TAG, "ID not watched: " + id); } else { Log.d(TAG, "Removing ID " + id); mBugreportInfos.remove(id); } // Must stop foreground service first, otherwise notif.cancel() will fail below. stopForegroundWhenDoneLocked(id); Log.d(TAG, "stopProgress(" + id + "): cancel notification"); NotificationManager.from(mContext).cancel(id); stopSelfWhenDoneLocked(); } /** * 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(); synchronized (mLock) { final BugreportInfo info = getInfoLocked(id); if (info != null && !info.finished.get()) { Log.i(TAG, "Cancelling bugreport service (ID=" + id + ") on user's request"); mBugreportManager.cancelBugreport(); info.deleteScreenshots(); info.deleteBugreportFile(); } stopProgressLocked(id); } } /** * 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; synchronized (mLock) { info = getInfoLocked(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 {@link #SCREENSHOT_DELAY_SECONDS} seconds before * taking the screenshot. */ private void takeScreenshot(int id) { MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_NOTIFICATION_ACTION_SCREENSHOT); BugreportInfo info; synchronized (mLock) { info = getInfoLocked(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, "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 {@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; } final BugreportInfo info; // It's time to take the screenshot: let the proper thread handle it synchronized (mLock) { info = getInfoLocked(id); } if (info == null) { return; } final String screenshotPath = new File(mBugreportsDir, info.getPathNextScreenshot()).getAbsolutePath(); Message.obtain(mScreenshotHandler, MSG_SCREENSHOT_REQUEST, id, UNUSED_ARG2, screenshotPath) .sendToTarget(); } /** * 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 (mLock) { mTakingScreenshot = flag; for (int i = 0; i < mBugreportInfos.size(); i++) { final BugreportInfo info = getInfoLocked(mBugreportInfos.keyAt(i)); if (info.finished.get()) { 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; synchronized (mLock) { info = getInfoLocked(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.get()) { Log.d(TAG, "Screenshot finished after bugreport; updating share notification"); info.renameScreenshots(); 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); } /** * Stop running on foreground once there is no more active bugreports being watched. */ @GuardedBy("mLock") private void stopForegroundWhenDoneLocked(int id) { if (id != mForegroundId) { Log.d(TAG, "stopForegroundWhenDoneLocked(" + 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 = mBugreportInfos.size(); if (total > 0) { for (int i = 0; i < total; i++) { final BugreportInfo info = getInfoLocked(mBugreportInfos.keyAt(i)); if (!info.finished.get()) { updateProgress(info); break; } } } } /** * Finishes the service when it's not monitoring any more processes. */ @GuardedBy("mLock") private void stopSelfWhenDoneLocked() { if (mBugreportInfos.size() > 0) { if (DEBUG) Log.d(TAG, "Staying alive, waiting for IDs " + mBugreportInfos); return; } Log.v(TAG, "No more processes to handle, shutting down"); stopSelf(); } /** * Wraps up bugreport generation and triggers a notification to share the bugreport. */ private void onBugreportFinished(BugreportInfo info) { if (!TextUtils.isEmpty(info.shareTitle)) { info.setTitle(info.shareTitle); } Log.d(TAG, "Bugreport finished with title: " + info.getTitle() + " and shareDescription: " + info.shareDescription); info.finished.set(true); synchronized (mLock) { // Stop running on foreground, otherwise share notification cannot be dismissed. stopForegroundWhenDoneLocked(info.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. */ 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(); synchronized (mLock) { stopProgressLocked(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 {@link Intent} that can be used to share the given bugreport. */ private static Intent buildSendIntent(Context context, BugreportInfo info) { // Rename files (if required) before sharing info.renameBugreportFile(); info.renameScreenshots(); // 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.getTitle()) ? info.getTitle() : 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.getDescription())) { messageBody.append("\nDescription: ").append(info.getDescription()); descriptionLength = info.getDescription().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 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 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 {@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; synchronized (mLock) { info = getInfoLocked(id); } if (info == null) { // Service was terminated but notification persisted info = sharedInfo; synchronized (mLock) { Log.d(TAG, "shareBugreport(): no info for ID " + id + " on managed processes (" + mBugreportInfos + "), 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"); synchronized (mLock) { stopProgressLocked(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); } synchronized (mLock) { // ... and stop watching this process. stopProgressLocked(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. */ 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.getTitle())) { title = mContext.getString(R.string.bugreport_finished_title, info.id); } else { title = info.getTitle(); 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.getName())) { builder.setSubText(info.getName()); } 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. */ 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) { synchronized (sNotificationBundle) { 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(R.drawable.ic_bug_report_black_24dp) .setLocalOnly(true) .setColor(context.getColor( com.android.internal.R.color.system_notification_accent_color)) .extend(new Notification.TvExtender()); } /** * Sends a zipped bugreport notification. */ private void sendZippedBugreportNotification( final BugreportInfo info, final boolean takingScreenshot) { new AsyncTask() { @Override protected Void doInBackground(Void... params) { Looper.prepare(); 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). */ 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 {@code title.txt} entry; similarly, the * description will be saved on {@code description.txt}. */ private void addDetailsToZipFile(BugreportInfo info) { synchronized (mLock) { addDetailsToZipFileLocked(info); } } @GuardedBy("mLock") 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.getTitle()) && TextUtils.isEmpty(info.getDescription())) { 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 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.getTitle()); addEntry(zos, "description.txt", info.getDescription()); } 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; stopForegroundWhenDoneLocked(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 {@link Account} based on build properties. If none found, returns * the first account that looks like an email address. */ @VisibleForTesting static Pair 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 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 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. */ static String dumpIntent(Intent intent) { if (intent == null) { return "NO INTENT"; } String action = intent.getAction(); if (action == null) { // Happens when startService is called... action = "no action"; } final StringBuilder buffer = new StringBuilder(action).append(" extras: "); addExtra(buffer, intent, EXTRA_ID); 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 user-provided details of a bugreport. */ private void updateBugreportInfo(int id, String name, String title, String description) { final BugreportInfo info; synchronized (mLock) { info = getInfoLocked(id); } if (info == null) { return; } if (title != null && !title.equals(info.getTitle())) { Log.d(TAG, "updating bugreport title: " + title); MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_TITLE_CHANGED); } info.setTitle(title); if (description != null && !description.equals(info.getDescription())) { Log.d(TAG, "updating bugreport description: " + description.length() + " chars"); MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_DESCRIPTION_CHANGED); } info.setDescription(description); if (name != null && !name.equals(info.getName())) { Log.d(TAG, "updating bugreport name: " + name); MetricsLogger.action(this, MetricsEvent.ACTION_BUGREPORT_DETAILS_NAME_CHANGED); info.setName(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. */ 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; } static boolean isTv(Context context) { return context.getPackageManager().hasSystemFeature(PackageManager.FEATURE_LEANBACK); } /** * 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. */ private final class BugreportInfoDialog { private EditText mInfoName; private EditText mInfoTitle; private EditText mInfoDescription; private AlertDialog mDialog; private Button mOkButton; private int mId; /** * 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); final Context themedContext = new ContextThemeWrapper( context, com.android.internal.R.style.Theme_DeviceDefault_DayNight); // First initializes singleton. if (mDialog == null) { @SuppressLint("InflateParams") // It's ok pass null ViewRoot on AlertDialogs. final View view = View.inflate(themedContext, 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); mDialog = new AlertDialog.Builder(themedContext) .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); } }) .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. mId = info.id; if (!TextUtils.isEmpty(info.getName())) { mInfoName.setText(info.getName()); } if (!TextUtils.isEmpty(info.getTitle())) { mInfoTitle.setText(info.getTitle()); } if (!TextUtils.isEmpty(info.getDescription())) { mInfoDescription.setText(info.getDescription()); } // 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(info.getName()); 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 {@code name} field, automatically replacing * invalid characters if necessary. */ private void sanitizeName(String savedName) { String name = mInfoName.getText().toString(); if (name.equals(savedName)) { 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); } } void cancel() { if (mDialog != null) { mDialog.cancel(); } } } /** * 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. */ int id; /** * Prefix name of the bugreport, this is uneditable. * The baseName consists of the string "bugreport" + deviceName + buildID * This will end with the string "wifi"/"telephony" for wifi/telephony bugreports. * Bugreport zip file name = "-.zip" */ private final String baseName; /** * Suffix name of the bugreport/screenshot, is set to timestamp initially. User can make * modifications to this using interface. */ private String name; /** * Initial value of the field name. This is required to rename the files later on, as they * are created using initial value of name. */ private final String initialName; /** * User-provided, one-line summary of the bug; when set, will be used as the subject * of the {@link Intent#ACTION_SEND_MULTIPLE} intent. */ private String title; /** * One-line summary of the bug; when set, will be used as the subject of the * {@link Intent#ACTION_SEND_MULTIPLE} intent. This is the predefined title which is * set initially when the request to take a bugreport is made. This overrides any changes * in the title that the user makes after the bugreport starts. */ private final String shareTitle; /** * User-provided, detailed description of the bugreport; when set, will be added to the body * of the {@link Intent#ACTION_SEND_MULTIPLE} intent. This is shown in the app where the * bugreport is being shared as an attachment. This is not related/dependant on * {@code shareDescription}. */ private String description; /** * Current value of progress (in percentage) of the bugreport generation as * displayed by the UI. */ final AtomicInteger progress = new AtomicInteger(0); /** * Last value of progress (in percentage) of the bugreport generation for which * system notification was updated. */ final AtomicInteger lastProgress = new AtomicInteger(0); /** * Time of the last progress update. */ final AtomicLong lastUpdate = new AtomicLong(System.currentTimeMillis()); /** * Time of the last progress update when Parcel was created. */ String formattedLastUpdate; /** * Path of the main bugreport file. */ File bugreportFile; /** * Path of the screenshot files. */ List screenshotFiles = new ArrayList<>(1); /** * Whether dumpstate sent an intent informing it has finished. */ final AtomicBoolean finished = new AtomicBoolean(false); /** * Whether the details entries have been added to the bugreport yet. */ boolean addingDetailsToZip; boolean addedDetailsToZip; /** * Internal counter used to name screenshot files. */ int screenshotCounter; /** * Descriptive text that will be shown to the user in the notification message. This is the * predefined description which is set initially when the request to take a bugreport is * made. */ private final String shareDescription; /** * Type of the bugreport */ final int type; private final Object mLock = new Object(); /** * Constructor for tracked bugreports - typically called upon receiving BUGREPORT_REQUESTED. */ BugreportInfo(Context context, String baseName, String name, @Nullable String shareTitle, @Nullable String shareDescription, @BugreportParams.BugreportMode int type, File bugreportsDir) { this.context = context; this.name = this.initialName = name; this.shareTitle = shareTitle == null ? "" : shareTitle; this.shareDescription = shareDescription == null ? "" : shareDescription; this.type = type; this.baseName = baseName; createBugreportFile(bugreportsDir); createScreenshotFile(bugreportsDir); } void createBugreportFile(File bugreportsDir) { bugreportFile = new File(bugreportsDir, getFileName(this, ".zip")); createReadWriteFile(bugreportFile); } void createScreenshotFile(File bugreportsDir) { File screenshotFile = new File(bugreportsDir, getScreenshotName("default")); addScreenshot(screenshotFile); createReadWriteFile(screenshotFile); } ParcelFileDescriptor getBugreportFd() { return getFd(bugreportFile); } ParcelFileDescriptor getDefaultScreenshotFd() { if (screenshotFiles.isEmpty()) { return null; } return getFd(screenshotFiles.get(0)); } void setTitle(String title) { synchronized (mLock) { this.title = title; } } String getTitle() { synchronized (mLock) { return title; } } void setName(String name) { synchronized (mLock) { this.name = name; } } String getName() { synchronized (mLock) { return name; } } void setDescription(String description) { synchronized (mLock) { this.description = description; } } String getDescription() { synchronized (mLock) { return description; } } /** * Gets the name for next user triggered screenshot file. */ String getPathNextScreenshot() { screenshotCounter ++; return getScreenshotName(Integer.toString(screenshotCounter)); } /** * Gets the name for screenshot file based on the suffix that is passed. */ String getScreenshotName(String suffix) { return "screenshot-" + initialName + "-" + suffix + ".png"; } /** * Saves the location of a taken screenshot so it can be sent out at the end. */ void addScreenshot(File screenshot) { screenshotFiles.add(screenshot); } /** * Deletes all screenshots taken for a given bugreport. */ private void deleteScreenshots() { for (File file : screenshotFiles) { Log.i(TAG, "Deleting screenshot file " + file); file.delete(); } } /** * Deletes bugreport file for a given bugreport. */ private void deleteBugreportFile() { Log.i(TAG, "Deleting bugreport file " + bugreportFile); bugreportFile.delete(); } /** * Deletes empty files for a given bugreport. */ private void deleteEmptyFiles() { if (bugreportFile.length() == 0) { Log.i(TAG, "Deleting empty bugreport file: " + bugreportFile); bugreportFile.delete(); } for (File file : screenshotFiles) { if (file.length() == 0) { Log.i(TAG, "Deleting empty screenshot file: " + file); file.delete(); } } } /** * Rename all screenshots files so that they contain the new {@code name} instead of the * {@code initialName} if user has changed it. */ void renameScreenshots() { if (TextUtils.isEmpty(name)) { return; } final List renamedFiles = new ArrayList<>(screenshotFiles.size()); for (File oldFile : screenshotFiles) { final String oldName = oldFile.getName(); final String newName = oldName.replaceFirst(initialName, name); final File newFile; if (!newName.equals(oldName)) { final File renamedFile = new File(oldFile.getParentFile(), 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); newFile = oldFile; } if (newFile.length() > 0) { renamedFiles.add(newFile); } else if (newFile.delete()) { Log.d(TAG, "screenshot file: " + newFile + "deleted successfully."); } } screenshotFiles = renamedFiles; } /** * Rename bugreport file to include the name given by user via UI */ void renameBugreportFile() { File newBugreportFile = new File(bugreportFile.getParentFile(), getFileName(this, ".zip")); if (!newBugreportFile.getPath().equals(bugreportFile.getPath())) { if (bugreportFile.renameTo(newBugreportFile)) { bugreportFile = newBugreportFile; } } } String getFormattedLastUpdate() { if (context == null) { // Restored from Parcel return formattedLastUpdate == null ? Long.toString(lastUpdate.longValue()) : formattedLastUpdate; } return DateUtils.formatDateTime(context, lastUpdate.longValue(), DateUtils.FORMAT_SHOW_DATE | DateUtils.FORMAT_SHOW_TIME); } @Override public String toString() { final StringBuilder builder = new StringBuilder() .append("\tid: ").append(id) .append(", baseName: ").append(baseName) .append(", name: ").append(name) .append(", initialName: ").append(initialName) .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("\n\tlast_update: ").append(getFormattedLastUpdate()) .append("\n\taddingDetailsToZip: ").append(addingDetailsToZip) .append(" addedDetailsToZip: ").append(addedDetailsToZip) .append("\n\tshareDescription: ").append(shareDescription) .append("\n\tshareTitle: ").append(shareTitle) .toString(); } // Parcelable contract protected BugreportInfo(Parcel in) { context = null; id = in.readInt(); baseName = in.readString(); name = in.readString(); initialName = in.readString(); title = in.readString(); description = in.readString(); progress.set(in.readInt()); lastUpdate.set(in.readLong()); formattedLastUpdate = in.readString(); bugreportFile = readFile(in); int screenshotSize = in.readInt(); for (int i = 1; i <= screenshotSize; i++) { screenshotFiles.add(readFile(in)); } finished.set(in.readInt() == 1); screenshotCounter = in.readInt(); shareDescription = in.readString(); shareTitle = in.readString(); type = in.readInt(); } @Override public void writeToParcel(Parcel dest, int flags) { dest.writeInt(id); dest.writeString(baseName); dest.writeString(name); dest.writeString(initialName); dest.writeString(title); dest.writeString(description); dest.writeInt(progress.intValue()); dest.writeLong(lastUpdate.longValue()); dest.writeString(getFormattedLastUpdate()); writeFile(dest, bugreportFile); dest.writeInt(screenshotFiles.size()); for (File screenshotFile : screenshotFiles) { writeFile(dest, screenshotFile); } dest.writeInt(finished.get() ? 1 : 0); dest.writeInt(screenshotCounter); dest.writeString(shareDescription); dest.writeString(shareTitle); dest.writeInt(type); } @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 CREATOR = new Parcelable.Creator() { @Override public BugreportInfo createFromParcel(Parcel source) { return new BugreportInfo(source); } @Override public BugreportInfo[] newArray(int size) { return new BugreportInfo[size]; } }; } @GuardedBy("mLock") private void checkProgressUpdatedLocked(BugreportInfo info, int progress) { if (progress > CAPPED_PROGRESS) { progress = CAPPED_PROGRESS; } if (DEBUG) { if (progress != info.progress.intValue()) { Log.v(TAG, "Updating progress for name " + info.getName() + "(id: " + info.id + ") from " + info.progress.intValue() + " to " + progress); } } info.progress.set(progress); info.lastUpdate.set(System.currentTimeMillis()); updateProgress(info); } }