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