1 /* 2 * Copyright (C) 2019 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.google.android.car.bugreport; 17 18 import static com.google.android.car.bugreport.PackageUtils.getPackageVersion; 19 20 import android.annotation.FloatRange; 21 import android.annotation.StringRes; 22 import android.app.Notification; 23 import android.app.NotificationChannel; 24 import android.app.NotificationManager; 25 import android.app.PendingIntent; 26 import android.app.Service; 27 import android.car.Car; 28 import android.car.CarBugreportManager; 29 import android.car.CarNotConnectedException; 30 import android.content.Intent; 31 import android.os.Binder; 32 import android.os.Build; 33 import android.os.Bundle; 34 import android.os.Handler; 35 import android.os.IBinder; 36 import android.os.Message; 37 import android.os.ParcelFileDescriptor; 38 import android.util.Log; 39 import android.widget.Toast; 40 41 import com.google.common.util.concurrent.AtomicDouble; 42 43 import libcore.io.IoUtils; 44 45 import java.io.BufferedOutputStream; 46 import java.io.DataInputStream; 47 import java.io.DataOutputStream; 48 import java.io.File; 49 import java.io.FileInputStream; 50 import java.io.FileOutputStream; 51 import java.io.IOException; 52 import java.io.InputStream; 53 import java.io.OutputStream; 54 import java.util.Enumeration; 55 import java.util.concurrent.Executors; 56 import java.util.concurrent.ScheduledExecutorService; 57 import java.util.concurrent.TimeUnit; 58 import java.util.concurrent.atomic.AtomicBoolean; 59 import java.util.zip.ZipEntry; 60 import java.util.zip.ZipFile; 61 import java.util.zip.ZipOutputStream; 62 63 /** 64 * Service that captures screenshot and bug report using dumpstate and bluetooth snoop logs. 65 * 66 * <p>After collecting all the logs it updates the {@link MetaBugReport} using {@link 67 * BugStorageProvider}, which in turn schedules bug report to upload. 68 */ 69 public class BugReportService extends Service { 70 private static final String TAG = BugReportService.class.getSimpleName(); 71 72 /** 73 * Extra data from intent - current bug report. 74 */ 75 static final String EXTRA_META_BUG_REPORT = "meta_bug_report"; 76 77 // Wait a short time before starting to capture the bugreport and the screen, so that 78 // bugreport activity can detach from the view tree. 79 // It is ugly to have a timeout, but it is ok here because such a delay should not really 80 // cause bugreport to be tainted with so many other events. If in the future we want to change 81 // this, the best option is probably to wait for onDetach events from view tree. 82 private static final int ACTIVITY_FINISH_DELAY_MILLIS = 1000; 83 84 private static final String BT_SNOOP_LOG_LOCATION = "/data/misc/bluetooth/logs/btsnoop_hci.log"; 85 private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); 86 87 /** Notifications on this channel will silently appear in notification bar. */ 88 private static final String PROGRESS_CHANNEL_ID = "BUGREPORT_PROGRESS_CHANNEL"; 89 90 /** Notifications on this channel will pop-up. */ 91 private static final String STATUS_CHANNEL_ID = "BUGREPORT_STATUS_CHANNEL"; 92 93 private static final int BUGREPORT_IN_PROGRESS_NOTIF_ID = 1; 94 95 /** The notification is shown when bugreport is collected. */ 96 static final int BUGREPORT_FINISHED_NOTIF_ID = 2; 97 98 private static final String OUTPUT_ZIP_FILE = "output_file.zip"; 99 private static final String EXTRA_OUTPUT_ZIP_FILE = "extra_output_file.zip"; 100 101 private static final String MESSAGE_FAILURE_DUMPSTATE = "Failed to grab dumpstate"; 102 private static final String MESSAGE_FAILURE_ZIP = "Failed to zip files"; 103 104 private static final int PROGRESS_HANDLER_EVENT_PROGRESS = 1; 105 private static final String PROGRESS_HANDLER_DATA_PROGRESS = "progress"; 106 107 static final float MAX_PROGRESS_VALUE = 100f; 108 109 /** Binder given to clients. */ 110 private final IBinder mBinder = new ServiceBinder(); 111 112 private final AtomicBoolean mIsCollectingBugReport = new AtomicBoolean(false); 113 private final AtomicDouble mBugReportProgress = new AtomicDouble(0); 114 115 private MetaBugReport mMetaBugReport; 116 private NotificationManager mNotificationManager; 117 private ScheduledExecutorService mSingleThreadExecutor; 118 private BugReportProgressListener mBugReportProgressListener; 119 private Car mCar; 120 private CarBugreportManager mBugreportManager; 121 private CarBugreportManager.CarBugreportManagerCallback mCallback; 122 123 /** A handler on the main thread. */ 124 private Handler mHandler; 125 126 /** A listener that's notified when bugreport progress changes. */ 127 interface BugReportProgressListener { 128 /** 129 * Called when bug report progress changes. 130 * 131 * @param progress - a bug report progress in [0.0, 100.0]. 132 */ onProgress(float progress)133 void onProgress(float progress); 134 } 135 136 /** Client binder. */ 137 public class ServiceBinder extends Binder { getService()138 BugReportService getService() { 139 // Return this instance of LocalService so clients can call public methods 140 return BugReportService.this; 141 } 142 } 143 144 /** A handler on a main thread. */ 145 private class BugReportHandler extends Handler { 146 @Override handleMessage(Message message)147 public void handleMessage(Message message) { 148 switch (message.what) { 149 case PROGRESS_HANDLER_EVENT_PROGRESS: 150 if (mBugReportProgressListener != null) { 151 float progress = message.getData().getFloat(PROGRESS_HANDLER_DATA_PROGRESS); 152 mBugReportProgressListener.onProgress(progress); 153 } 154 showProgressNotification(); 155 break; 156 default: 157 Log.d(TAG, "Unknown event " + message.what + ", ignoring."); 158 } 159 } 160 } 161 162 @Override onCreate()163 public void onCreate() { 164 mNotificationManager = getSystemService(NotificationManager.class); 165 mNotificationManager.createNotificationChannel(new NotificationChannel( 166 PROGRESS_CHANNEL_ID, 167 getString(R.string.notification_bugreport_channel_name), 168 NotificationManager.IMPORTANCE_DEFAULT)); 169 mNotificationManager.createNotificationChannel(new NotificationChannel( 170 STATUS_CHANNEL_ID, 171 getString(R.string.notification_bugreport_channel_name), 172 NotificationManager.IMPORTANCE_HIGH)); 173 mSingleThreadExecutor = Executors.newSingleThreadScheduledExecutor(); 174 mHandler = new BugReportHandler(); 175 mCar = Car.createCar(this); 176 try { 177 mBugreportManager = (CarBugreportManager) mCar.getCarManager(Car.CAR_BUGREPORT_SERVICE); 178 } catch (CarNotConnectedException | NoClassDefFoundError e) { 179 Log.w(TAG, "Couldn't get CarBugreportManager", e); 180 } 181 } 182 183 @Override onStartCommand(final Intent intent, int flags, int startId)184 public int onStartCommand(final Intent intent, int flags, int startId) { 185 if (mIsCollectingBugReport.get()) { 186 Log.w(TAG, "bug report is already being collected, ignoring"); 187 Toast.makeText(this, R.string.toast_bug_report_in_progress, Toast.LENGTH_SHORT).show(); 188 return START_NOT_STICKY; 189 } 190 Log.i(TAG, String.format("Will start collecting bug report, version=%s", 191 getPackageVersion(this))); 192 mIsCollectingBugReport.set(true); 193 mBugReportProgress.set(0); 194 195 startForeground(BUGREPORT_IN_PROGRESS_NOTIF_ID, buildProgressNotification()); 196 showProgressNotification(); 197 198 Bundle extras = intent.getExtras(); 199 mMetaBugReport = extras.getParcelable(EXTRA_META_BUG_REPORT); 200 201 collectBugReport(); 202 203 // If the service process gets killed due to heavy memory pressure, do not restart. 204 return START_NOT_STICKY; 205 } 206 207 /** Shows an updated progress notification. */ showProgressNotification()208 private void showProgressNotification() { 209 if (isCollectingBugReport()) { 210 mNotificationManager.notify( 211 BUGREPORT_IN_PROGRESS_NOTIF_ID, buildProgressNotification()); 212 } 213 } 214 buildProgressNotification()215 private Notification buildProgressNotification() { 216 return new Notification.Builder(this, PROGRESS_CHANNEL_ID) 217 .setContentTitle(getText(R.string.notification_bugreport_in_progress)) 218 .setSubText(String.format("%.1f%%", mBugReportProgress.get())) 219 .setSmallIcon(R.drawable.download_animation) 220 .setCategory(Notification.CATEGORY_STATUS) 221 .setOngoing(true) 222 .setProgress((int) MAX_PROGRESS_VALUE, (int) mBugReportProgress.get(), false) 223 .build(); 224 } 225 226 /** Returns true if bugreporting is in progress. */ isCollectingBugReport()227 public boolean isCollectingBugReport() { 228 return mIsCollectingBugReport.get(); 229 } 230 231 /** Returns current bugreport progress. */ getBugReportProgress()232 public float getBugReportProgress() { 233 return (float) mBugReportProgress.get(); 234 } 235 236 /** Sets a bugreport progress listener. The listener is called on a main thread. */ setBugReportProgressListener(BugReportProgressListener listener)237 public void setBugReportProgressListener(BugReportProgressListener listener) { 238 mBugReportProgressListener = listener; 239 } 240 241 /** Removes the bugreport progress listener. */ removeBugReportProgressListener()242 public void removeBugReportProgressListener() { 243 mBugReportProgressListener = null; 244 } 245 246 @Override onBind(Intent intent)247 public IBinder onBind(Intent intent) { 248 return mBinder; 249 } 250 showToast(@tringRes int resId)251 private void showToast(@StringRes int resId) { 252 // run on ui thread. 253 mHandler.post(() -> Toast.makeText(this, getText(resId), Toast.LENGTH_LONG).show()); 254 } 255 collectBugReport()256 private void collectBugReport() { 257 if (Build.IS_USERDEBUG || Build.IS_ENG) { 258 mSingleThreadExecutor.schedule( 259 this::grabBtSnoopLog, ACTIVITY_FINISH_DELAY_MILLIS, TimeUnit.MILLISECONDS); 260 } 261 mSingleThreadExecutor.schedule( 262 this::saveBugReport, ACTIVITY_FINISH_DELAY_MILLIS, TimeUnit.MILLISECONDS); 263 } 264 grabBtSnoopLog()265 private void grabBtSnoopLog() { 266 Log.i(TAG, "Grabbing bt snoop log"); 267 File result = FileUtils.getFileWithSuffix(this, mMetaBugReport.getTimestamp(), 268 "-btsnoop.bin.log"); 269 try { 270 copyBinaryStream(new FileInputStream(new File(BT_SNOOP_LOG_LOCATION)), 271 new FileOutputStream(result)); 272 } catch (IOException e) { 273 // this regularly happens when snooplog is not enabled so do not log as an error 274 Log.i(TAG, "Failed to grab bt snooplog, continuing to take bug report.", e); 275 } 276 } 277 saveBugReport()278 private void saveBugReport() { 279 Log.i(TAG, "Dumpstate to file"); 280 File outputFile = FileUtils.getFile(this, mMetaBugReport.getTimestamp(), OUTPUT_ZIP_FILE); 281 File extraOutputFile = FileUtils.getFile(this, mMetaBugReport.getTimestamp(), 282 EXTRA_OUTPUT_ZIP_FILE); 283 try (ParcelFileDescriptor outFd = ParcelFileDescriptor.open(outputFile, 284 ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_WRITE); 285 ParcelFileDescriptor extraOutFd = ParcelFileDescriptor.open(extraOutputFile, 286 ParcelFileDescriptor.MODE_CREATE | ParcelFileDescriptor.MODE_READ_WRITE)) { 287 requestBugReport(outFd, extraOutFd); 288 } catch (IOException | RuntimeException e) { 289 Log.e(TAG, "Failed to grab dump state", e); 290 BugStorageUtils.setBugReportStatus(this, mMetaBugReport, Status.STATUS_WRITE_FAILED, 291 MESSAGE_FAILURE_DUMPSTATE); 292 showToast(R.string.toast_status_dump_state_failed); 293 } 294 } 295 sendProgressEventToHandler(float progress)296 private void sendProgressEventToHandler(float progress) { 297 Message message = new Message(); 298 message.what = PROGRESS_HANDLER_EVENT_PROGRESS; 299 message.getData().putFloat(PROGRESS_HANDLER_DATA_PROGRESS, progress); 300 mHandler.sendMessage(message); 301 } 302 requestBugReport(ParcelFileDescriptor outFd, ParcelFileDescriptor extraOutFd)303 private void requestBugReport(ParcelFileDescriptor outFd, ParcelFileDescriptor extraOutFd) { 304 if (DEBUG) { 305 Log.d(TAG, "Requesting a bug report from CarBugReportManager."); 306 } 307 mCallback = new CarBugreportManager.CarBugreportManagerCallback() { 308 @Override 309 public void onError(int errorCode) { 310 Log.e(TAG, "Bugreport failed " + errorCode); 311 showToast(R.string.toast_status_failed); 312 // TODO(b/133520419): show this error on Info page or add to zip file. 313 scheduleZipTask(); 314 // We let the UI know that bug reporting is finished, because the next step is to 315 // zip everything and upload. 316 mBugReportProgress.set(MAX_PROGRESS_VALUE); 317 sendProgressEventToHandler(MAX_PROGRESS_VALUE); 318 } 319 320 @Override 321 public void onProgress(@FloatRange(from = 0f, to = MAX_PROGRESS_VALUE) float progress) { 322 mBugReportProgress.set(progress); 323 sendProgressEventToHandler(progress); 324 } 325 326 @Override 327 public void onFinished() { 328 Log.i(TAG, "Bugreport finished"); 329 scheduleZipTask(); 330 mBugReportProgress.set(MAX_PROGRESS_VALUE); 331 sendProgressEventToHandler(MAX_PROGRESS_VALUE); 332 } 333 }; 334 mBugreportManager.requestBugreport(outFd, extraOutFd, mCallback); 335 } 336 scheduleZipTask()337 private void scheduleZipTask() { 338 mSingleThreadExecutor.submit(this::zipDirectoryAndScheduleForUpload); 339 } 340 341 /** 342 * Shows a clickable bugreport finished notification. When clicked it opens 343 * {@link BugReportInfoActivity}. 344 */ showBugReportFinishedNotification()345 private void showBugReportFinishedNotification() { 346 Intent intent = new Intent(getApplicationContext(), BugReportInfoActivity.class); 347 PendingIntent startBugReportInfoActivity = 348 PendingIntent.getActivity(getApplicationContext(), 0, intent, 0); 349 Notification notification = new Notification 350 .Builder(getApplicationContext(), STATUS_CHANNEL_ID) 351 .setContentTitle(getText(R.string.notification_bugreport_finished_title)) 352 .setContentText(getText(JobSchedulingUtils.uploadByDefault() 353 ? R.string.notification_bugreport_auto_upload_finished_text 354 : R.string.notification_bugreport_manual_upload_finished_text)) 355 .setCategory(Notification.CATEGORY_STATUS) 356 .setSmallIcon(R.drawable.ic_upload) 357 .setContentIntent(startBugReportInfoActivity) 358 .build(); 359 mNotificationManager.notify(BUGREPORT_FINISHED_NOTIF_ID, notification); 360 } 361 zipDirectoryAndScheduleForUpload()362 private void zipDirectoryAndScheduleForUpload() { 363 try { 364 // When OutputStream from openBugReportFile is closed, BugStorageProvider automatically 365 // schedules an upload job. 366 zipDirectoryToOutputStream( 367 FileUtils.createTempDir(this, mMetaBugReport.getTimestamp()), 368 BugStorageUtils.openBugReportFile(this, mMetaBugReport)); 369 showBugReportFinishedNotification(); 370 } catch (IOException e) { 371 Log.e(TAG, "Failed to zip files", e); 372 BugStorageUtils.setBugReportStatus(this, mMetaBugReport, Status.STATUS_WRITE_FAILED, 373 MESSAGE_FAILURE_ZIP); 374 showToast(R.string.toast_status_failed); 375 } 376 mIsCollectingBugReport.set(false); 377 showToast(R.string.toast_status_finished); 378 mHandler.post(() -> stopForeground(true)); 379 } 380 381 @Override onDestroy()382 public void onDestroy() { 383 if (DEBUG) { 384 Log.d(TAG, "Service destroyed"); 385 } 386 } 387 copyBinaryStream(InputStream in, OutputStream out)388 private static void copyBinaryStream(InputStream in, OutputStream out) throws IOException { 389 OutputStream writer = null; 390 InputStream reader = null; 391 try { 392 writer = new DataOutputStream(out); 393 reader = new DataInputStream(in); 394 rawCopyStream(writer, reader); 395 } finally { 396 IoUtils.closeQuietly(reader); 397 IoUtils.closeQuietly(writer); 398 } 399 } 400 401 // does not close the reader or writer. rawCopyStream(OutputStream writer, InputStream reader)402 private static void rawCopyStream(OutputStream writer, InputStream reader) throws IOException { 403 int read; 404 byte[] buf = new byte[8192]; 405 while ((read = reader.read(buf, 0, buf.length)) > 0) { 406 writer.write(buf, 0, read); 407 } 408 } 409 410 /** 411 * Compresses a directory into a zip file. The method is not recursive. Any sub-directory 412 * contained in the main directory and any files contained in the sub-directories will be 413 * skipped. 414 * 415 * @param dirToZip The path of the directory to zip 416 * @param outStream The output stream to write the zip file to 417 * @throws IOException if the directory does not exist, its files cannot be read, or the output 418 * zip file cannot be written. 419 */ zipDirectoryToOutputStream(File dirToZip, OutputStream outStream)420 private void zipDirectoryToOutputStream(File dirToZip, OutputStream outStream) 421 throws IOException { 422 if (!dirToZip.isDirectory()) { 423 throw new IOException("zip directory does not exist"); 424 } 425 Log.v(TAG, "zipping directory " + dirToZip.getAbsolutePath()); 426 427 File[] listFiles = dirToZip.listFiles(); 428 ZipOutputStream zipStream = new ZipOutputStream(new BufferedOutputStream(outStream)); 429 try { 430 for (File file : listFiles) { 431 if (file.isDirectory()) { 432 continue; 433 } 434 String filename = file.getName(); 435 436 // only for the zipped output file, we add invidiual entries to zip file 437 if (filename.equals(OUTPUT_ZIP_FILE) || filename.equals(EXTRA_OUTPUT_ZIP_FILE)) { 438 extractZippedFileToOutputStream(file, zipStream); 439 } else { 440 FileInputStream reader = new FileInputStream(file); 441 addFileToOutputStream(filename, reader, zipStream); 442 } 443 } 444 } finally { 445 zipStream.close(); 446 outStream.close(); 447 } 448 // Zipping successful, now cleanup the temp dir. 449 FileUtils.deleteDirectory(dirToZip); 450 } 451 extractZippedFileToOutputStream(File file, ZipOutputStream zipStream)452 private void extractZippedFileToOutputStream(File file, ZipOutputStream zipStream) 453 throws IOException { 454 ZipFile zipFile = new ZipFile(file); 455 Enumeration<? extends ZipEntry> entries = zipFile.entries(); 456 while (entries.hasMoreElements()) { 457 ZipEntry entry = entries.nextElement(); 458 InputStream stream = zipFile.getInputStream(entry); 459 addFileToOutputStream(entry.getName(), stream, zipStream); 460 } 461 } 462 addFileToOutputStream(String filename, InputStream reader, ZipOutputStream zipStream)463 private void addFileToOutputStream(String filename, InputStream reader, 464 ZipOutputStream zipStream) throws IOException { 465 ZipEntry entry = new ZipEntry(filename); 466 zipStream.putNextEntry(entry); 467 rawCopyStream(zipStream, reader); 468 zipStream.closeEntry(); 469 reader.close(); 470 } 471 } 472