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