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