blob: 981faab93722e9fd6b87d66367ade7e85d69a7e5 [file] [log] [blame]
/*
* Copyright (C) 2019 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.google.android.car.bugreport;
import static com.google.android.car.bugreport.PackageUtils.getPackageVersion;
import android.annotation.FloatRange;
import android.annotation.StringRes;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.Service;
import android.car.Car;
import android.car.CarBugreportManager;
import android.car.CarNotConnectedException;
import android.content.Intent;
import android.os.Binder;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Message;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import android.widget.Toast;
import com.google.common.util.concurrent.AtomicDouble;
import libcore.io.IoUtils;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Enumeration;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
/**
* Service that captures screenshot and bug report using dumpstate and bluetooth snoop logs.
*
* <p>After collecting all the logs it updates the {@link MetaBugReport} using {@link
* BugStorageProvider}, which in turn schedules bug report to upload.
*/
public class BugReportService extends Service {
private static final String TAG = BugReportService.class.getSimpleName();
/**
* Extra data from intent - current bug report.
*/
static final String EXTRA_META_BUG_REPORT = "meta_bug_report";
// Wait a short time before starting to capture the bugreport and the screen, so that
// bugreport activity can detach from the view tree.
// It is ugly to have a timeout, but it is ok here because such a delay should not really
// cause bugreport to be tainted with so many other events. If in the future we want to change
// this, the best option is probably to wait for onDetach events from view tree.
private static final int ACTIVITY_FINISH_DELAY = 1000; //in milliseconds
private static final String BT_SNOOP_LOG_LOCATION = "/data/misc/bluetooth/logs/btsnoop_hci.log";
private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);
private static final String NOTIFICATION_STATUS_CHANNEL_ID = "BUGREPORT_STATUS_CHANNEL_ID";
private static final int BUGREPORT_IN_PROGRESS_NOTIF_ID = 1;
private static final String OUTPUT_ZIP_FILE = "output_file.zip";
private static final String EXTRA_OUTPUT_ZIP_FILE = "extra_output_file.zip";
private static final String MESSAGE_FAILURE_DUMPSTATE = "Failed to grab dumpstate";
private static final String MESSAGE_FAILURE_ZIP = "Failed to zip files";
private static final int PROGRESS_HANDLER_EVENT_PROGRESS = 1;
private static final String PROGRESS_HANDLER_DATA_PROGRESS = "progress";
static final float MAX_PROGRESS_VALUE = 100f;
/** Binder given to clients. */
private final IBinder mBinder = new ServiceBinder();
private final AtomicBoolean mIsCollectingBugReport = new AtomicBoolean(false);
private final AtomicDouble mBugReportProgress = new AtomicDouble(0);
private MetaBugReport mMetaBugReport;
private NotificationManager mNotificationManager;
private NotificationChannel mNotificationChannel;
private ScheduledExecutorService mSingleThreadExecutor;
private BugReportProgressListener mBugReportProgressListener;
private Car mCar;
private CarBugreportManager mBugreportManager;
private CarBugreportManager.CarBugreportManagerCallback mCallback;
/** A handler on the main thread. */
private Handler mHandler;
/** A listener that's notified when bugreport progress changes. */
interface BugReportProgressListener {
/**
* Called when bug report progress changes.
*
* @param progress - a bug report progress in [0.0, 100.0].
*/
void onProgress(float progress);
}
/** Client binder. */
public class ServiceBinder extends Binder {
BugReportService getService() {
// Return this instance of LocalService so clients can call public methods
return BugReportService.this;
}
}
/** A handler on a main thread. */
private class BugReportHandler extends Handler {
@Override
public void handleMessage(Message message) {
switch (message.what) {
case PROGRESS_HANDLER_EVENT_PROGRESS:
if (mBugReportProgressListener != null) {
float progress = message.getData().getFloat(PROGRESS_HANDLER_DATA_PROGRESS);
mBugReportProgressListener.onProgress(progress);
}
break;
default:
Log.d(TAG, "Unknown event " + message.what + ", ignoring.");
}
}
}
@Override
public void onCreate() {
mNotificationManager = getSystemService(NotificationManager.class);
mNotificationChannel = new NotificationChannel(
NOTIFICATION_STATUS_CHANNEL_ID,
getString(R.string.notification_bugreport_channel_name),
NotificationManager.IMPORTANCE_MIN);
mNotificationManager.createNotificationChannel(mNotificationChannel);
mSingleThreadExecutor = Executors.newSingleThreadScheduledExecutor();
mHandler = new BugReportHandler();
mCar = Car.createCar(this);
try {
mBugreportManager = (CarBugreportManager) mCar.getCarManager(Car.CAR_BUGREPORT_SERVICE);
} catch (CarNotConnectedException | NoClassDefFoundError e) {
Log.w(TAG, "Couldn't get CarBugreportManager", e);
}
}
@Override
public int onStartCommand(final Intent intent, int flags, int startId) {
if (mIsCollectingBugReport.get()) {
Log.w(TAG, "bug report is already being collected, ignoring");
Toast.makeText(this, R.string.toast_bug_report_in_progress, Toast.LENGTH_SHORT).show();
return START_NOT_STICKY;
}
Log.i(TAG, String.format("Will start collecting bug report, version=%s",
getPackageVersion(this)));
mIsCollectingBugReport.set(true);
mBugReportProgress.set(0);
Notification notification =
new Notification.Builder(this, NOTIFICATION_STATUS_CHANNEL_ID)
.setContentTitle(getText(R.string.notification_bugreport_started))
.setSmallIcon(R.drawable.download_animation)
.build();
startForeground(BUGREPORT_IN_PROGRESS_NOTIF_ID, notification);
Bundle extras = intent.getExtras();
mMetaBugReport = extras.getParcelable(EXTRA_META_BUG_REPORT);
collectBugReport();
// If the service process gets killed due to heavy memory pressure, do not restart.
return START_NOT_STICKY;
}
/** Returns true if bugreporting is in progress. */
public boolean isCollectingBugReport() {
return mIsCollectingBugReport.get();
}
/** Returns current bugreport progress. */
public float getBugReportProgress() {
return (float) mBugReportProgress.get();
}
/** Sets a bugreport progress listener. The listener is called on a main thread. */
public void setBugReportProgressListener(BugReportProgressListener listener) {
mBugReportProgressListener = listener;
}
/** Removes the bugreport progress listener. */
public void removeBugReportProgressListener() {
mBugReportProgressListener = null;
}
@Override
public IBinder onBind(Intent intent) {
return mBinder;
}
private void showToast(@StringRes int resId) {
// run on ui thread.
mHandler.post(() -> Toast.makeText(this, getText(resId), Toast.LENGTH_LONG).show());
}
private void collectBugReport() {
if (Build.IS_USERDEBUG || Build.IS_ENG) {
mSingleThreadExecutor.schedule(
this::grabBtSnoopLog, ACTIVITY_FINISH_DELAY, TimeUnit.MILLISECONDS);
}
mSingleThreadExecutor.schedule(
this::saveBugReport, ACTIVITY_FINISH_DELAY, TimeUnit.MILLISECONDS);
}
private void grabBtSnoopLog() {
Log.i(TAG, "Grabbing bt snoop log");
File result = FileUtils.getFileWithSuffix(this, mMetaBugReport.getTimestamp(),
"-btsnoop.bin.log");
try {
copyBinaryStream(new FileInputStream(new File(BT_SNOOP_LOG_LOCATION)),
new FileOutputStream(result));
} catch (IOException e) {
// this regularly happens when snooplog is not enabled so do not log as an error
Log.i(TAG, "Failed to grab bt snooplog, continuing to take bug report.", e);
}
}
private void saveBugReport() {
Log.i(TAG, "Dumpstate to file");
File outputFile = FileUtils.getFile(this, mMetaBugReport.getTimestamp(), OUTPUT_ZIP_FILE);
File extraOutputFile = FileUtils.getFile(this, mMetaBugReport.getTimestamp(),
EXTRA_OUTPUT_ZIP_FILE);
try (ParcelFileDescriptor outFd = ParcelFileDescriptor.open(outputFile,
ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_WRITE);
ParcelFileDescriptor extraOutFd = ParcelFileDescriptor.open(extraOutputFile,
ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_WRITE)) {
requestBugReport(outFd, extraOutFd);
} catch (IOException | RuntimeException e) {
Log.e(TAG, "Failed to grab dump state", e);
BugStorageUtils.setBugReportStatus(this, mMetaBugReport, Status.STATUS_WRITE_FAILED,
MESSAGE_FAILURE_DUMPSTATE);
showToast(R.string.toast_status_dump_state_failed);
}
}
private void sendProgressEventToHandler(float progress) {
Message message = new Message();
message.what = PROGRESS_HANDLER_EVENT_PROGRESS;
message.getData().putFloat(PROGRESS_HANDLER_DATA_PROGRESS, progress);
mHandler.sendMessage(message);
}
private void requestBugReport(ParcelFileDescriptor outFd, ParcelFileDescriptor extraOutFd) {
if (DEBUG) {
Log.d(TAG, "Requesting a bug report from CarBugReportManager.");
}
mCallback = new CarBugreportManager.CarBugreportManagerCallback() {
@Override
public void onError(int errorCode) {
Log.e(TAG, "Bugreport failed " + errorCode);
showToast(R.string.toast_status_failed);
// TODO(b/133520419): show this error on Info page or add to zip file.
scheduleZipTask();
// We let the UI know that bug reporting is finished, because the next step is to
// zip everything and upload.
mBugReportProgress.set(MAX_PROGRESS_VALUE);
sendProgressEventToHandler(MAX_PROGRESS_VALUE);
}
@Override
public void onProgress(@FloatRange(from = 0f, to = MAX_PROGRESS_VALUE) float progress) {
mBugReportProgress.set(progress);
sendProgressEventToHandler(progress);
}
@Override
public void onFinished() {
Log.i(TAG, "Bugreport finished");
scheduleZipTask();
mBugReportProgress.set(MAX_PROGRESS_VALUE);
sendProgressEventToHandler(MAX_PROGRESS_VALUE);
}
};
mBugreportManager.requestBugreport(outFd, extraOutFd, mCallback);
}
private void scheduleZipTask() {
mSingleThreadExecutor.submit(this::zipDirectoryAndScheduleForUpload);
}
private void zipDirectoryAndScheduleForUpload() {
try {
// When OutputStream from openBugReportFile is closed, BugStorageProvider automatically
// schedules an upload job.
zipDirectoryToOutputStream(
FileUtils.createTempDir(this, mMetaBugReport.getTimestamp()),
BugStorageUtils.openBugReportFile(this, mMetaBugReport));
} catch (IOException e) {
Log.e(TAG, "Failed to zip files", e);
BugStorageUtils.setBugReportStatus(this, mMetaBugReport, Status.STATUS_WRITE_FAILED,
MESSAGE_FAILURE_ZIP);
showToast(R.string.toast_status_failed);
}
mIsCollectingBugReport.set(false);
showToast(R.string.toast_status_finished);
}
@Override
public void onDestroy() {
if (DEBUG) {
Log.d(TAG, "Service destroyed");
}
}
private static void copyBinaryStream(InputStream in, OutputStream out) throws IOException {
OutputStream writer = null;
InputStream reader = null;
try {
writer = new DataOutputStream(out);
reader = new DataInputStream(in);
rawCopyStream(writer, reader);
} finally {
IoUtils.closeQuietly(reader);
IoUtils.closeQuietly(writer);
}
}
// does not close the reader or writer.
private static void rawCopyStream(OutputStream writer, InputStream reader) throws IOException {
int read;
byte[] buf = new byte[8192];
while ((read = reader.read(buf, 0, buf.length)) > 0) {
writer.write(buf, 0, read);
}
}
/**
* Compresses a directory into a zip file. The method is not recursive. Any sub-directory
* contained in the main directory and any files contained in the sub-directories will be
* skipped.
*
* @param dirToZip The path of the directory to zip
* @param outStream The output stream to write the zip file to
* @throws IOException if the directory does not exist, its files cannot be read, or the output
* zip file cannot be written.
*/
private void zipDirectoryToOutputStream(File dirToZip, OutputStream outStream)
throws IOException {
if (!dirToZip.isDirectory()) {
throw new IOException("zip directory does not exist");
}
Log.v(TAG, "zipping directory " + dirToZip.getAbsolutePath());
File[] listFiles = dirToZip.listFiles();
ZipOutputStream zipStream = new ZipOutputStream(new BufferedOutputStream(outStream));
try {
for (File file : listFiles) {
if (file.isDirectory()) {
continue;
}
String filename = file.getName();
// only for the zipped output file, we add invidiual entries to zip file
if (filename.equals(OUTPUT_ZIP_FILE) || filename.equals(EXTRA_OUTPUT_ZIP_FILE)) {
extractZippedFileToOutputStream(file, zipStream);
} else {
FileInputStream reader = new FileInputStream(file);
addFileToOutputStream(filename, reader, zipStream);
}
}
} finally {
zipStream.close();
outStream.close();
}
// Zipping successful, now cleanup the temp dir.
FileUtils.deleteDirectory(dirToZip);
}
private void extractZippedFileToOutputStream(File file, ZipOutputStream zipStream)
throws IOException {
ZipFile zipFile = new ZipFile(file);
Enumeration<? extends ZipEntry> entries = zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
InputStream stream = zipFile.getInputStream(entry);
addFileToOutputStream(entry.getName(), stream, zipStream);
}
}
private void addFileToOutputStream(String filename, InputStream reader,
ZipOutputStream zipStream) throws IOException {
ZipEntry entry = new ZipEntry(filename);
zipStream.putNextEntry(entry);
rawCopyStream(zipStream, reader);
zipStream.closeEntry();
reader.close();
}
}