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