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