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_AUDIO_FIRST + "," 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_AUDIO_FIRST); 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 = Config.create(); 238 return true; 239 } 240 241 @Override query( @onNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder)242 public Cursor query( 243 @NonNull Uri uri, 244 @Nullable String[] projection, 245 @Nullable String selection, 246 @Nullable String[] selectionArgs, 247 @Nullable String sortOrder) { 248 return query(uri, projection, selection, selectionArgs, sortOrder, null); 249 } 250 251 @Nullable 252 @Override query( @onNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder, @Nullable CancellationSignal cancellationSignal)253 public Cursor query( 254 @NonNull Uri uri, 255 @Nullable String[] projection, 256 @Nullable String selection, 257 @Nullable String[] selectionArgs, 258 @Nullable String sortOrder, 259 @Nullable CancellationSignal cancellationSignal) { 260 Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled."); 261 String table; 262 switch (mUriMatcher.match(uri)) { 263 // returns the list of bugreports that match the selection criteria. 264 case URL_MATCHED_BUG_REPORTS_URI: 265 table = BUG_REPORTS_TABLE; 266 break; 267 // returns the bugreport that match the id. 268 case URL_MATCHED_BUG_REPORT_ID_URI: 269 table = BUG_REPORTS_TABLE; 270 if (selection != null || selectionArgs != null) { 271 throw new IllegalArgumentException("selection is not allowed for " 272 + URL_MATCHED_BUG_REPORT_ID_URI); 273 } 274 selection = COLUMN_ID + "=?"; 275 selectionArgs = new String[]{ uri.getLastPathSegment() }; 276 break; 277 default: 278 throw new IllegalArgumentException("Unknown URL " + uri); 279 } 280 SQLiteDatabase db = mDatabaseHelper.getReadableDatabase(); 281 Cursor cursor = db.query(false, table, null, selection, selectionArgs, null, null, 282 sortOrder, null, cancellationSignal); 283 cursor.setNotificationUri(getContext().getContentResolver(), uri); 284 return cursor; 285 } 286 287 @Nullable 288 @Override insert(@onNull Uri uri, @Nullable ContentValues values)289 public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) { 290 Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled."); 291 String table; 292 if (values == null) { 293 throw new IllegalArgumentException("values cannot be null"); 294 } 295 switch (mUriMatcher.match(uri)) { 296 case URL_MATCHED_BUG_REPORTS_URI: 297 table = BUG_REPORTS_TABLE; 298 break; 299 default: 300 throw new IllegalArgumentException("unknown uri" + uri); 301 } 302 SQLiteDatabase db = mDatabaseHelper.getWritableDatabase(); 303 long rowId = db.insert(table, null, values); 304 if (rowId > 0) { 305 Uri resultUri = Uri.parse("content://" + AUTHORITY + "/" + table + "/" + rowId); 306 // notify registered content observers 307 getContext().getContentResolver().notifyChange(resultUri, null); 308 return resultUri; 309 } 310 return null; 311 } 312 313 @Nullable 314 @Override getType(@onNull Uri uri)315 public String getType(@NonNull Uri uri) { 316 switch (mUriMatcher.match(uri)) { 317 case URL_MATCHED_OPEN_BUGREPORT_FILE: 318 case URL_MATCHED_OPEN_FILE: 319 return "application/zip"; 320 case URL_MATCHED_OPEN_AUDIO_FILE: 321 return "audio/3gpp"; 322 default: 323 throw new IllegalArgumentException("unknown uri:" + uri); 324 } 325 } 326 327 @Override delete( @onNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs)328 public int delete( 329 @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) { 330 Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled."); 331 SQLiteDatabase db = mDatabaseHelper.getReadableDatabase(); 332 switch (mUriMatcher.match(uri)) { 333 case URL_MATCHED_DELETE_FILES: 334 if (selection != null || selectionArgs != null) { 335 throw new IllegalArgumentException("selection is not allowed for " 336 + URL_MATCHED_DELETE_FILES); 337 } 338 if (deleteFilesFor(getBugReportFromUri(uri))) { 339 getContext().getContentResolver().notifyChange(uri, null); 340 return 1; 341 } 342 return 0; 343 case URL_MATCHED_COMPLETE_DELETE: 344 if (selection != null || selectionArgs != null) { 345 throw new IllegalArgumentException("selection is not allowed for " 346 + URL_MATCHED_COMPLETE_DELETE); 347 } 348 selection = COLUMN_ID + " = ?"; 349 selectionArgs = new String[]{uri.getLastPathSegment()}; 350 // Ignore the results of zip file deletion, possibly it wasn't even created. 351 deleteFilesFor(getBugReportFromUri(uri)); 352 getContext().getContentResolver().notifyChange(uri, null); 353 return db.delete(BUG_REPORTS_TABLE, selection, selectionArgs); 354 case URL_MATCHED_EXPIRE: 355 if (selection != null || selectionArgs != null) { 356 throw new IllegalArgumentException("selection is not allowed for " 357 + URL_MATCHED_EXPIRE); 358 } 359 if (deleteFilesFor(getBugReportFromUri(uri))) { 360 ContentValues values = new ContentValues(); 361 values.put(COLUMN_STATUS, Status.STATUS_EXPIRED.getValue()); 362 values.put(COLUMN_STATUS_MESSAGE, "Expired at " + Instant.now()); 363 selection = COLUMN_ID + " = ?"; 364 selectionArgs = new String[]{uri.getLastPathSegment()}; 365 int rowCount = db.update(BUG_REPORTS_TABLE, values, selection, selectionArgs); 366 getContext().getContentResolver().notifyChange(uri, null); 367 return rowCount; 368 } 369 return 0; 370 default: 371 throw new IllegalArgumentException("Unknown URL " + uri); 372 } 373 } 374 375 @Override update( @onNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs)376 public int update( 377 @NonNull Uri uri, 378 @Nullable ContentValues values, 379 @Nullable String selection, 380 @Nullable String[] selectionArgs) { 381 Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled."); 382 if (values == null) { 383 throw new IllegalArgumentException("values cannot be null"); 384 } 385 String table; 386 switch (mUriMatcher.match(uri)) { 387 case URL_MATCHED_BUG_REPORTS_URI: 388 table = BUG_REPORTS_TABLE; 389 break; 390 default: 391 throw new IllegalArgumentException("Unknown URL " + uri); 392 } 393 SQLiteDatabase db = mDatabaseHelper.getWritableDatabase(); 394 int rowCount = db.update(table, values, selection, selectionArgs); 395 if (rowCount > 0) { 396 // notify registered content observers 397 getContext().getContentResolver().notifyChange(uri, null); 398 } 399 Integer status = values.getAsInteger(COLUMN_STATUS); 400 // When the status is set to STATUS_UPLOAD_PENDING, we schedule an UploadJob under the 401 // current user, which is the primary user. 402 if (status != null && status.equals(Status.STATUS_UPLOAD_PENDING.getValue())) { 403 JobSchedulingUtils.scheduleUploadJob(BugStorageProvider.this.getContext()); 404 } 405 return rowCount; 406 } 407 408 /** 409 * This is called when a file is opened. 410 * 411 * <p>See {@link BugStorageUtils#openBugReportFileToWrite}, 412 * {@link BugStorageUtils#openAudioMessageFileToWrite}. 413 */ 414 @Nullable 415 @Override openFile(@onNull Uri uri, @NonNull String mode)416 public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode) 417 throws FileNotFoundException { 418 Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled."); 419 Function<MetaBugReport, String> fileNameExtractor; 420 switch (mUriMatcher.match(uri)) { 421 case URL_MATCHED_OPEN_BUGREPORT_FILE: 422 fileNameExtractor = MetaBugReport::getBugReportFileName; 423 break; 424 case URL_MATCHED_OPEN_AUDIO_FILE: 425 fileNameExtractor = MetaBugReport::getAudioFileName; 426 break; 427 case URL_MATCHED_OPEN_FILE: 428 File file = new File(getBugReportFromUri(uri).getFilePath()); 429 Log.v(TAG, "Opening file " + file + " with mode " + mode); 430 return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode)); 431 default: 432 throw new IllegalArgumentException("unknown uri:" + uri); 433 } 434 // URI contains bugreport ID as the last segment, see the matched urls. 435 MetaBugReport bugReport = getBugReportFromUri(uri); 436 File file = new File( 437 FileUtils.getPendingDir(getContext()), fileNameExtractor.apply(bugReport)); 438 Log.v(TAG, "Opening file " + file + " with mode " + mode); 439 int modeBits = ParcelFileDescriptor.parseMode(mode); 440 return ParcelFileDescriptor.open(file, modeBits); 441 } 442 getBugReportFromUri(@onNull Uri uri)443 private MetaBugReport getBugReportFromUri(@NonNull Uri uri) { 444 int bugreportId = Integer.parseInt(uri.getLastPathSegment()); 445 return BugStorageUtils.findBugReport(getContext(), bugreportId) 446 .orElseThrow(() -> new IllegalArgumentException("No record found for " + uri)); 447 } 448 449 /** 450 * Print the Provider's state into the given stream. This gets invoked if 451 * you run "dumpsys activity provider com.android.car.bugreport/.BugStorageProvider". 452 * 453 * @param fd The raw file descriptor that the dump is being sent to. 454 * @param writer The PrintWriter to which you should dump your state. This will be 455 * closed for you after you return. 456 * @param args additional arguments to the dump request. 457 */ dump(FileDescriptor fd, PrintWriter writer, String[] args)458 public void dump(FileDescriptor fd, PrintWriter writer, String[] args) { 459 writer.println("BugStorageProvider:"); 460 mConfig.dump(/* prefix= */ " ", writer); 461 } 462 deleteFilesFor(MetaBugReport bugReport)463 private boolean deleteFilesFor(MetaBugReport bugReport) { 464 File pendingDir = FileUtils.getPendingDir(getContext()); 465 boolean result = true; 466 if (!Strings.isNullOrEmpty(bugReport.getAudioFileName())) { 467 result = new File(pendingDir, bugReport.getAudioFileName()).delete(); 468 } 469 if (!Strings.isNullOrEmpty(bugReport.getBugReportFileName())) { 470 result = result && new File(pendingDir, bugReport.getBugReportFileName()).delete(); 471 } 472 return result; 473 } 474 } 475