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.android.car.bugreport; 17 18 import android.content.ContentProvider; 19 import android.content.ContentValues; 20 import android.content.Context; 21 import android.content.UriMatcher; 22 import android.database.Cursor; 23 import android.database.sqlite.SQLiteDatabase; 24 import android.database.sqlite.SQLiteOpenHelper; 25 import android.net.Uri; 26 import android.os.CancellationSignal; 27 import android.os.ParcelFileDescriptor; 28 import android.util.Log; 29 30 import androidx.annotation.NonNull; 31 import androidx.annotation.Nullable; 32 import androidx.annotation.StringDef; 33 34 import com.google.common.base.Preconditions; 35 import com.google.common.base.Strings; 36 37 import java.io.File; 38 import java.io.FileDescriptor; 39 import java.io.FileNotFoundException; 40 import java.io.FilenameFilter; 41 import java.io.PrintWriter; 42 import java.lang.annotation.Retention; 43 import java.lang.annotation.RetentionPolicy; 44 import java.time.Instant; 45 import java.util.Locale; 46 import java.util.function.Function; 47 48 49 /** 50 * Provides a bug storage interface to save and upload bugreports filed from all users. 51 * In Android Automotive user 0 runs as the system and all the time, while other users won't once 52 * their session ends. This content provider enables bug reports to be uploaded even after 53 * user session ends. 54 * 55 * <p>A bugreport constists of two files: bugreport zip file and audio file. Audio file is added 56 * later through notification. {@link SimpleUploaderAsyncTask} merges two files into one zip file 57 * before uploading. 58 * 59 * <p>All files are stored under system user's {@link FileUtils#getPendingDir}. 60 */ 61 public class BugStorageProvider extends ContentProvider { 62 private static final String TAG = BugStorageProvider.class.getSimpleName(); 63 64 private static final String AUTHORITY = "com.android.car.bugreport"; 65 private static final String BUG_REPORTS_TABLE = "bugreports"; 66 67 /** Deletes files associated with a bug report. */ 68 static final String URL_SEGMENT_DELETE_FILES = "deleteZipFile"; 69 /** Destructively deletes a bug report. */ 70 static final String URL_SEGMENT_COMPLETE_DELETE = "completeDelete"; 71 /** 72 * Deletes all files for given bugreport and sets the status to {@link Status#STATUS_EXPIRED}. 73 */ 74 static final String URL_SEGMENT_EXPIRE = "expire"; 75 /** Opens bugreport file of a bug report, uses column {@link #COLUMN_BUGREPORT_FILENAME}. */ 76 static final String URL_SEGMENT_OPEN_BUGREPORT_FILE = "openBugReportFile"; 77 /** Opens audio file of a bug report, uses column {@link #URL_MATCHED_OPEN_AUDIO_FILE}. */ 78 static final String URL_SEGMENT_OPEN_AUDIO_FILE = "openAudioFile"; 79 /** 80 * Opens final bugreport zip file, uses column {@link #COLUMN_FILEPATH}. 81 * 82 * <p>NOTE: This is the old way of storing final zipped bugreport. In 83 * {@code BugStorageProvider#AUDIO_VERSION} {@link #COLUMN_FILEPATH} is dropped. But there are 84 * still some devices with this field set. 85 */ 86 static final String URL_SEGMENT_OPEN_FILE = "openFile"; 87 88 // URL Matcher IDs. 89 private static final int URL_MATCHED_BUG_REPORTS_URI = 1; 90 private static final int URL_MATCHED_BUG_REPORT_ID_URI = 2; 91 private static final int URL_MATCHED_DELETE_FILES = 3; 92 private static final int URL_MATCHED_COMPLETE_DELETE = 4; 93 private static final int URL_MATCHED_EXPIRE = 5; 94 private static final int URL_MATCHED_OPEN_BUGREPORT_FILE = 6; 95 private static final int URL_MATCHED_OPEN_AUDIO_FILE = 7; 96 private static final int URL_MATCHED_OPEN_FILE = 8; 97 98 @StringDef({ 99 URL_SEGMENT_DELETE_FILES, 100 URL_SEGMENT_COMPLETE_DELETE, 101 URL_SEGMENT_OPEN_BUGREPORT_FILE, 102 URL_SEGMENT_OPEN_AUDIO_FILE, 103 URL_SEGMENT_OPEN_FILE, 104 }) 105 @Retention(RetentionPolicy.SOURCE) 106 @interface UriActionSegments { 107 } 108 109 static final Uri BUGREPORT_CONTENT_URI = 110 Uri.parse("content://" + AUTHORITY + "/" + BUG_REPORTS_TABLE); 111 112 /** See {@link MetaBugReport} for column descriptions. */ 113 static final String COLUMN_ID = "_ID"; 114 static final String COLUMN_USERNAME = "username"; 115 static final String COLUMN_TITLE = "title"; 116 static final String COLUMN_TIMESTAMP = "timestamp"; 117 /** not used anymore */ 118 static final String COLUMN_DESCRIPTION = "description"; 119 /** not used anymore, but some devices still might have bugreports with this field set. */ 120 static final String COLUMN_FILEPATH = "filepath"; 121 static final String COLUMN_STATUS = "status"; 122 static final String COLUMN_STATUS_MESSAGE = "message"; 123 static final String COLUMN_TYPE = "type"; 124 static final String COLUMN_BUGREPORT_FILENAME = "bugreport_filename"; 125 static final String COLUMN_AUDIO_FILENAME = "audio_filename"; 126 static final String COLUMN_TTL_POINTS = "ttl_points"; 127 /** 128 * Retaining bugreports for {@code 50} reboots is good enough. 129 * See {@link TtlPointsDecremental} for more details. 130 */ 131 private static final int DEFAULT_TTL_POINTS = 50; 132 133 private DatabaseHelper mDatabaseHelper; 134 private final UriMatcher mUriMatcher; 135 private Config mConfig; 136 137 /** 138 * A helper class to work with sqlite database. 139 */ 140 private static class DatabaseHelper extends SQLiteOpenHelper { 141 private static final String TAG = DatabaseHelper.class.getSimpleName(); 142 143 private static final String DATABASE_NAME = "bugreport.db"; 144 145 /** 146 * All changes in database versions should be recorded here. 147 * 1: Initial version. 148 * 2: Add integer column: type. 149 * 3: Add string column audio_filename and bugreport_filename. 150 * 4: Add integer column: ttl_points. 151 */ 152 private static final int INITIAL_VERSION = 1; 153 private static final int TYPE_VERSION = 2; 154 private static final int AUDIO_VERSION = 3; 155 private static final int TTL_POINTS_VERSION = 4; 156 private static final int DATABASE_VERSION = TTL_POINTS_VERSION; 157 158 private static final String CREATE_TABLE = "CREATE TABLE " + BUG_REPORTS_TABLE + " (" 159 + COLUMN_ID + " INTEGER PRIMARY KEY," 160 + COLUMN_USERNAME + " TEXT," 161 + COLUMN_TITLE + " TEXT," 162 + COLUMN_TIMESTAMP + " TEXT NOT NULL," 163 + COLUMN_DESCRIPTION + " TEXT NULL," 164 + COLUMN_FILEPATH + " TEXT DEFAULT NULL," 165 + COLUMN_STATUS + " INTEGER DEFAULT " + Status.STATUS_WRITE_PENDING.getValue() + "," 166 + COLUMN_STATUS_MESSAGE + " TEXT NULL," 167 + COLUMN_TYPE + " INTEGER DEFAULT " + MetaBugReport.TYPE_AUDIO_FIRST + "," 168 + COLUMN_BUGREPORT_FILENAME + " TEXT DEFAULT NULL," 169 + COLUMN_AUDIO_FILENAME + " TEXT DEFAULT NULL," 170 + COLUMN_TTL_POINTS + " INTEGER DEFAULT " + DEFAULT_TTL_POINTS 171 + ");"; 172 DatabaseHelper(Context context)173 DatabaseHelper(Context context) { 174 super(context, DATABASE_NAME, null, DATABASE_VERSION); 175 } 176 177 @Override onCreate(SQLiteDatabase db)178 public void onCreate(SQLiteDatabase db) { 179 db.execSQL(CREATE_TABLE); 180 } 181 182 @Override onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)183 public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { 184 Log.w(TAG, "Upgrading from " + oldVersion + " to " + newVersion); 185 if (oldVersion < TYPE_VERSION) { 186 db.execSQL("ALTER TABLE " + BUG_REPORTS_TABLE + " ADD COLUMN " 187 + COLUMN_TYPE + " INTEGER DEFAULT " + MetaBugReport.TYPE_AUDIO_FIRST); 188 } 189 if (oldVersion < AUDIO_VERSION) { 190 db.execSQL("ALTER TABLE " + BUG_REPORTS_TABLE + " ADD COLUMN " 191 + COLUMN_BUGREPORT_FILENAME + " TEXT DEFAULT NULL"); 192 db.execSQL("ALTER TABLE " + BUG_REPORTS_TABLE + " ADD COLUMN " 193 + COLUMN_AUDIO_FILENAME + " TEXT DEFAULT NULL"); 194 } 195 if (oldVersion < TTL_POINTS_VERSION) { 196 db.execSQL("ALTER TABLE " + BUG_REPORTS_TABLE + " ADD COLUMN " 197 + COLUMN_TTL_POINTS + " INTEGER DEFAULT " + DEFAULT_TTL_POINTS); 198 } 199 } 200 } 201 202 /** 203 * Builds an {@link Uri} that points to the single bug report and performs an action 204 * defined by given URI segment. 205 */ buildUriWithSegment(int bugReportId, @UriActionSegments String segment)206 static Uri buildUriWithSegment(int bugReportId, @UriActionSegments String segment) { 207 return Uri.parse("content://" + AUTHORITY + "/" + BUG_REPORTS_TABLE + "/" 208 + segment + "/" + bugReportId); 209 } 210 BugStorageProvider()211 public BugStorageProvider() { 212 mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH); 213 mUriMatcher.addURI(AUTHORITY, BUG_REPORTS_TABLE, URL_MATCHED_BUG_REPORTS_URI); 214 mUriMatcher.addURI(AUTHORITY, BUG_REPORTS_TABLE + "/#", URL_MATCHED_BUG_REPORT_ID_URI); 215 mUriMatcher.addURI( 216 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_DELETE_FILES + "/#", 217 URL_MATCHED_DELETE_FILES); 218 mUriMatcher.addURI( 219 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_COMPLETE_DELETE + "/#", 220 URL_MATCHED_COMPLETE_DELETE); 221 mUriMatcher.addURI( 222 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_EXPIRE + "/#", 223 URL_MATCHED_EXPIRE); 224 mUriMatcher.addURI( 225 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_OPEN_BUGREPORT_FILE + "/#", 226 URL_MATCHED_OPEN_BUGREPORT_FILE); 227 mUriMatcher.addURI( 228 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_OPEN_AUDIO_FILE + "/#", 229 URL_MATCHED_OPEN_AUDIO_FILE); 230 mUriMatcher.addURI( 231 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_OPEN_FILE + "/#", 232 URL_MATCHED_OPEN_FILE); 233 } 234 235 @Override onCreate()236 public boolean onCreate() { 237 if (!Config.isBugReportEnabled()) { 238 return false; 239 } 240 mDatabaseHelper = new DatabaseHelper(getContext()); 241 mConfig = Config.create(getContext()); 242 return true; 243 } 244 245 @Override query( @onNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder)246 public Cursor query( 247 @NonNull Uri uri, 248 @Nullable String[] projection, 249 @Nullable String selection, 250 @Nullable String[] selectionArgs, 251 @Nullable String sortOrder) { 252 return query(uri, projection, selection, selectionArgs, sortOrder, null); 253 } 254 255 @Nullable 256 @Override query( @onNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder, @Nullable CancellationSignal cancellationSignal)257 public Cursor query( 258 @NonNull Uri uri, 259 @Nullable String[] projection, 260 @Nullable String selection, 261 @Nullable String[] selectionArgs, 262 @Nullable String sortOrder, 263 @Nullable CancellationSignal cancellationSignal) { 264 Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled."); 265 String table; 266 switch (mUriMatcher.match(uri)) { 267 // returns the list of bugreports that match the selection criteria. 268 case URL_MATCHED_BUG_REPORTS_URI: 269 table = BUG_REPORTS_TABLE; 270 break; 271 // returns the bugreport that match the id. 272 case URL_MATCHED_BUG_REPORT_ID_URI: 273 table = BUG_REPORTS_TABLE; 274 if (selection != null || selectionArgs != null) { 275 throw new IllegalArgumentException("selection is not allowed for " 276 + URL_MATCHED_BUG_REPORT_ID_URI); 277 } 278 selection = COLUMN_ID + "=?"; 279 selectionArgs = new String[]{uri.getLastPathSegment()}; 280 break; 281 default: 282 throw new IllegalArgumentException("Unknown URL " + uri); 283 } 284 SQLiteDatabase db = mDatabaseHelper.getReadableDatabase(); 285 Cursor cursor = db.query(false, table, null, selection, selectionArgs, null, null, 286 sortOrder, null, cancellationSignal); 287 cursor.setNotificationUri(getContext().getContentResolver(), uri); 288 return cursor; 289 } 290 291 @Nullable 292 @Override insert(@onNull Uri uri, @Nullable ContentValues values)293 public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { 294 Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled."); 295 String table; 296 if (values == null) { 297 throw new IllegalArgumentException("values cannot be null"); 298 } 299 switch (mUriMatcher.match(uri)) { 300 case URL_MATCHED_BUG_REPORTS_URI: 301 table = BUG_REPORTS_TABLE; 302 break; 303 default: 304 throw new IllegalArgumentException("unknown uri" + uri); 305 } 306 SQLiteDatabase db = mDatabaseHelper.getWritableDatabase(); 307 long rowId = db.insert(table, null, values); 308 if (rowId > 0) { 309 Uri resultUri = Uri.parse("content://" + AUTHORITY + "/" + table + "/" + rowId); 310 // notify registered content observers 311 getContext().getContentResolver().notifyChange(resultUri, null); 312 return resultUri; 313 } 314 return null; 315 } 316 317 @Nullable 318 @Override getType(@onNull Uri uri)319 public String getType(@NonNull Uri uri) { 320 switch (mUriMatcher.match(uri)) { 321 case URL_MATCHED_OPEN_BUGREPORT_FILE: 322 case URL_MATCHED_OPEN_FILE: 323 return "application/zip"; 324 case URL_MATCHED_OPEN_AUDIO_FILE: 325 return "audio/3gpp"; 326 default: 327 throw new IllegalArgumentException("unknown uri:" + uri); 328 } 329 } 330 331 @Override delete( @onNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs)332 public int delete( 333 @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { 334 Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled."); 335 SQLiteDatabase db = mDatabaseHelper.getReadableDatabase(); 336 switch (mUriMatcher.match(uri)) { 337 case URL_MATCHED_DELETE_FILES: 338 if (selection != null || selectionArgs != null) { 339 throw new IllegalArgumentException("selection is not allowed for " 340 + URL_MATCHED_DELETE_FILES); 341 } 342 if (deleteFilesFor(getBugReportFromUri(uri))) { 343 getContext().getContentResolver().notifyChange(uri, null); 344 return 1; 345 } 346 return 0; 347 case URL_MATCHED_COMPLETE_DELETE: 348 if (selection != null || selectionArgs != null) { 349 throw new IllegalArgumentException("selection is not allowed for " 350 + URL_MATCHED_COMPLETE_DELETE); 351 } 352 selection = COLUMN_ID + " = ?"; 353 selectionArgs = new String[]{uri.getLastPathSegment()}; 354 // Ignore the results of zip file deletion, possibly it wasn't even created. 355 deleteFilesFor(getBugReportFromUri(uri)); 356 getContext().getContentResolver().notifyChange(uri, null); 357 return db.delete(BUG_REPORTS_TABLE, selection, selectionArgs); 358 case URL_MATCHED_EXPIRE: 359 if (selection != null || selectionArgs != null) { 360 throw new IllegalArgumentException("selection is not allowed for " 361 + URL_MATCHED_EXPIRE); 362 } 363 if (deleteFilesFor(getBugReportFromUri(uri))) { 364 ContentValues values = new ContentValues(); 365 values.put(COLUMN_STATUS, Status.STATUS_EXPIRED.getValue()); 366 values.put(COLUMN_STATUS_MESSAGE, "Expired at " + Instant.now()); 367 selection = COLUMN_ID + " = ?"; 368 selectionArgs = new String[]{uri.getLastPathSegment()}; 369 int rowCount = db.update(BUG_REPORTS_TABLE, values, selection, selectionArgs); 370 getContext().getContentResolver().notifyChange(uri, null); 371 return rowCount; 372 } 373 return 0; 374 default: 375 throw new IllegalArgumentException("Unknown URL " + uri); 376 } 377 } 378 379 @Override update( @onNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs)380 public int update( 381 @NonNull Uri uri, 382 @Nullable ContentValues values, 383 @Nullable String selection, 384 @Nullable String[] selectionArgs) { 385 Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled."); 386 if (values == null) { 387 throw new IllegalArgumentException("values cannot be null"); 388 } 389 String table; 390 switch (mUriMatcher.match(uri)) { 391 case URL_MATCHED_BUG_REPORTS_URI: 392 table = BUG_REPORTS_TABLE; 393 break; 394 default: 395 throw new IllegalArgumentException("Unknown URL " + uri); 396 } 397 SQLiteDatabase db = mDatabaseHelper.getWritableDatabase(); 398 int rowCount = db.update(table, values, selection, selectionArgs); 399 if (rowCount > 0) { 400 // notify registered content observers 401 getContext().getContentResolver().notifyChange(uri, null); 402 } 403 Integer status = values.getAsInteger(COLUMN_STATUS); 404 // When the status is set to STATUS_UPLOAD_PENDING, we schedule an UploadJob under the 405 // current user, which is the primary user. 406 if (status != null && status.equals(Status.STATUS_UPLOAD_PENDING.getValue())) { 407 JobSchedulingUtils.scheduleUploadJob(BugStorageProvider.this.getContext()); 408 } 409 return rowCount; 410 } 411 412 /** 413 * This is called when a file is opened. 414 * 415 * <p>See {@link BugStorageUtils#openBugReportFileToWrite}, 416 * {@link BugStorageUtils#openAudioMessageFileToWrite}. 417 */ 418 @Nullable 419 @Override openFile(@onNull Uri uri, @NonNull String mode)420 public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) 421 throws FileNotFoundException { 422 Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled."); 423 Function<MetaBugReport, String> fileNameExtractor; 424 switch (mUriMatcher.match(uri)) { 425 case URL_MATCHED_OPEN_BUGREPORT_FILE: 426 fileNameExtractor = MetaBugReport::getBugReportFileName; 427 break; 428 case URL_MATCHED_OPEN_AUDIO_FILE: 429 fileNameExtractor = MetaBugReport::getAudioFileName; 430 break; 431 case URL_MATCHED_OPEN_FILE: 432 File file = new File(getBugReportFromUri(uri).getFilePath()); 433 Log.v(TAG, "Opening file " + file + " with mode " + mode); 434 return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode)); 435 default: 436 throw new IllegalArgumentException("unknown uri:" + uri); 437 } 438 // URI contains bugreport ID as the last segment, see the matched urls. 439 MetaBugReport bugReport = getBugReportFromUri(uri); 440 File file = new File( 441 FileUtils.getPendingDir(getContext()), fileNameExtractor.apply(bugReport)); 442 Log.v(TAG, "Opening file " + file + " with mode " + mode); 443 int modeBits = ParcelFileDescriptor.parseMode(mode); 444 return ParcelFileDescriptor.open(file, modeBits); 445 } 446 getBugReportFromUri(@onNull Uri uri)447 private MetaBugReport getBugReportFromUri(@NonNull Uri uri) { 448 int bugreportId = Integer.parseInt(uri.getLastPathSegment()); 449 return BugStorageUtils.findBugReport(getContext(), bugreportId) 450 .orElseThrow(() -> new IllegalArgumentException("No record found for " + uri)); 451 } 452 453 /** 454 * Print the Provider's state into the given stream. This gets invoked if 455 * you run "dumpsys activity provider com.android.car.bugreport/.BugStorageProvider". 456 * 457 * @param fd The raw file descriptor that the dump is being sent to. 458 * @param writer The PrintWriter to which you should dump your state. This will be 459 * closed for you after you return. 460 * @param args additional arguments to the dump request. 461 */ dump(FileDescriptor fd, PrintWriter writer, String[] args)462 public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { 463 writer.println("BugStorageProvider:"); 464 mConfig.dump(/* prefix= */ " ", writer); 465 } 466 deleteFilesFor(MetaBugReport bugReport)467 private boolean deleteFilesFor(MetaBugReport bugReport) { 468 File pendingDir = FileUtils.getPendingDir(getContext()); 469 boolean result = true; 470 // This ensures file deletion in case the file name does not contain lookup code. 471 if (!Strings.isNullOrEmpty(bugReport.getAudioFileName())) { 472 result = new File(pendingDir, bugReport.getAudioFileName()).delete(); 473 } 474 if (!Strings.isNullOrEmpty(bugReport.getBugReportFileName())) { 475 result = result && new File(pendingDir, bugReport.getBugReportFileName()).delete(); 476 } 477 478 // Because MetaBugReport holds only the current report and audio files, this finds and 479 // deletes the unlinked audio files from re-recording. This code requires the file name 480 // includes the same lookup code. 481 String lookupCode = FileUtils.extractLookupCode(bugReport); 482 FilenameFilter filter = (folder, name) -> name.toLowerCase(Locale.ROOT).contains( 483 lookupCode.toLowerCase(Locale.ROOT)); 484 485 File[] filesToDelete = pendingDir.listFiles(filter); 486 for (File file : filesToDelete) { 487 FileUtils.deleteFileQuietly(file); 488 } 489 490 return result; 491 } 492 } 493