blob: 6b4c239f2b5a6e594a373226938db00f50161838 [file] [log] [blame]
/*
* 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.
* <p>
* The workflow uses Bugreport API({@code BugreportManager}) and is as follows:
* <ol>
* <li>System apps like Settings or SysUI broadcasts {@code BUGREPORT_REQUESTED}.
* <li>{@link BugreportRequestedReceiver} receives the intent and delegates it to this service.
* <li>This service calls startBugreport() and passes in local file descriptors to receive
* bugreport artifacts.
* </ol>
*/
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.
* <p>
* 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.
* <p>
* 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<BugreportInfo> 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.
* <p>
* 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<Void, Void, Void>() {
@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 | PendingIntent.FLAG_IMMUTABLE);
}
/**
* 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.
* <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);
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<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 {@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 | PendingIntent.FLAG_IMMUTABLE))
.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<Void, Void, Void>() {
@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.
* <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);
}
}
@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<? 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.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<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.
*/
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 = "<baseName>-<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<File> 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<File> 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<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];
}
};
}
@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);
}
}