• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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