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