1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.google.android.car.bugreport;
17 
18 import android.annotation.NonNull;
19 import android.annotation.Nullable;
20 import android.annotation.StringDef;
21 import android.content.ContentProvider;
22 import android.content.ContentValues;
23 import android.content.Context;
24 import android.content.UriMatcher;
25 import android.database.Cursor;
26 import android.database.sqlite.SQLiteDatabase;
27 import android.database.sqlite.SQLiteOpenHelper;
28 import android.net.Uri;
29 import android.os.CancellationSignal;
30 import android.os.ParcelFileDescriptor;
31 import android.util.Log;
32 
33 import com.google.common.base.Preconditions;
34 import com.google.common.base.Strings;
35 
36 import java.io.File;
37 import java.io.FileDescriptor;
38 import java.io.FileNotFoundException;
39 import java.io.PrintWriter;
40 import java.lang.annotation.Retention;
41 import java.lang.annotation.RetentionPolicy;
42 import java.util.function.Function;
43 
44 
45 /**
46  * Provides a bug storage interface to save and upload bugreports filed from all users.
47  * In Android Automotive user 0 runs as the system and all the time, while other users won't once
48  * their session ends. This content provider enables bug reports to be uploaded even after
49  * user session ends.
50  *
51  * <p>A bugreport constists of two files: bugreport zip file and audio file. Audio file is added
52  * later through notification. {@link SimpleUploaderAsyncTask} merges two files into one zip file
53  * before uploading.
54  *
55  * <p>All files are stored under system user's {@link FileUtils#getPendingDir}.
56  */
57 public class BugStorageProvider extends ContentProvider {
58     private static final String TAG = BugStorageProvider.class.getSimpleName();
59 
60     private static final String AUTHORITY = "com.google.android.car.bugreport";
61     private static final String BUG_REPORTS_TABLE = "bugreports";
62 
63     /** Deletes files associated with a bug report. */
64     static final String URL_SEGMENT_DELETE_FILES = "deleteZipFile";
65     /** Destructively deletes a bug report. */
66     static final String URL_SEGMENT_COMPLETE_DELETE = "completeDelete";
67     /** Opens bugreport file of a bug report, uses column {@link #COLUMN_BUGREPORT_FILENAME}. */
68     static final String URL_SEGMENT_OPEN_BUGREPORT_FILE = "openBugReportFile";
69     /** Opens audio file of a bug report, uses column {@link #URL_MATCHED_OPEN_AUDIO_FILE}. */
70     static final String URL_SEGMENT_OPEN_AUDIO_FILE = "openAudioFile";
71     /**
72      * Opens final bugreport zip file, uses column {@link #COLUMN_FILEPATH}.
73      *
74      * <p>NOTE: This is the old way of storing final zipped bugreport. In
75      * {@code BugStorageProvider#AUDIO_VERSION} {@link #COLUMN_FILEPATH} is dropped. But there are
76      * still some devices with this field set.
77      */
78     static final String URL_SEGMENT_OPEN_FILE = "openFile";
79 
80     // URL Matcher IDs.
81     private static final int URL_MATCHED_BUG_REPORTS_URI = 1;
82     private static final int URL_MATCHED_BUG_REPORT_ID_URI = 2;
83     private static final int URL_MATCHED_DELETE_FILES = 3;
84     private static final int URL_MATCHED_COMPLETE_DELETE = 4;
85     private static final int URL_MATCHED_OPEN_BUGREPORT_FILE = 5;
86     private static final int URL_MATCHED_OPEN_AUDIO_FILE = 6;
87     private static final int URL_MATCHED_OPEN_FILE = 7;
88 
89     @StringDef({
90             URL_SEGMENT_DELETE_FILES,
91             URL_SEGMENT_COMPLETE_DELETE,
92             URL_SEGMENT_OPEN_BUGREPORT_FILE,
93             URL_SEGMENT_OPEN_AUDIO_FILE,
94             URL_SEGMENT_OPEN_FILE,
95     })
96     @Retention(RetentionPolicy.SOURCE)
97     @interface UriActionSegments {}
98 
99     static final Uri BUGREPORT_CONTENT_URI =
100             Uri.parse("content://" + AUTHORITY + "/" + BUG_REPORTS_TABLE);
101 
102     /** See {@link MetaBugReport} for column descriptions. */
103     static final String COLUMN_ID = "_ID";
104     static final String COLUMN_USERNAME = "username";
105     static final String COLUMN_TITLE = "title";
106     static final String COLUMN_TIMESTAMP = "timestamp";
107     /** not used anymore */
108     static final String COLUMN_DESCRIPTION = "description";
109     /** not used anymore, but some devices still might have bugreports with this field set. */
110     static final String COLUMN_FILEPATH = "filepath";
111     static final String COLUMN_STATUS = "status";
112     static final String COLUMN_STATUS_MESSAGE = "message";
113     static final String COLUMN_TYPE = "type";
114     static final String COLUMN_BUGREPORT_FILENAME = "bugreport_filename";
115     static final String COLUMN_AUDIO_FILENAME = "audio_filename";
116 
117     private DatabaseHelper mDatabaseHelper;
118     private final UriMatcher mUriMatcher;
119     private Config mConfig;
120 
121     /**
122      * A helper class to work with sqlite database.
123      */
124     private static class DatabaseHelper extends SQLiteOpenHelper {
125         private static final String TAG = DatabaseHelper.class.getSimpleName();
126 
127         private static final String DATABASE_NAME = "bugreport.db";
128 
129         /**
130          * All changes in database versions should be recorded here.
131          * 1: Initial version.
132          * 2: Add integer column details_needed.
133          * 3: Add string column audio_filename and bugreport_filename.
134          */
135         private static final int INITIAL_VERSION = 1;
136         private static final int TYPE_VERSION = 2;
137         private static final int AUDIO_VERSION = 3;
138         private static final int DATABASE_VERSION = AUDIO_VERSION;
139 
140         private static final String CREATE_TABLE = "CREATE TABLE " + BUG_REPORTS_TABLE + " ("
141                 + COLUMN_ID + " INTEGER PRIMARY KEY,"
142                 + COLUMN_USERNAME + " TEXT,"
143                 + COLUMN_TITLE + " TEXT,"
144                 + COLUMN_TIMESTAMP + " TEXT NOT NULL,"
145                 + COLUMN_DESCRIPTION + " TEXT NULL,"
146                 + COLUMN_FILEPATH + " TEXT DEFAULT NULL,"
147                 + COLUMN_STATUS + " INTEGER DEFAULT " + Status.STATUS_WRITE_PENDING.getValue() + ","
148                 + COLUMN_STATUS_MESSAGE + " TEXT NULL,"
149                 + COLUMN_TYPE + " INTEGER DEFAULT " + MetaBugReport.TYPE_INTERACTIVE + ","
150                 + COLUMN_BUGREPORT_FILENAME + " TEXT DEFAULT NULL,"
151                 + COLUMN_AUDIO_FILENAME + " TEXT DEFAULT NULL"
152                 + ");";
153 
DatabaseHelper(Context context)154         DatabaseHelper(Context context) {
155             super(context, DATABASE_NAME, null, DATABASE_VERSION);
156         }
157 
158         @Override
onCreate(SQLiteDatabase db)159         public void onCreate(SQLiteDatabase db) {
160             db.execSQL(CREATE_TABLE);
161         }
162 
163         @Override
onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion)164         public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
165             Log.w(TAG, "Upgrading from " + oldVersion + " to " + newVersion);
166             if (oldVersion < TYPE_VERSION) {
167                 db.execSQL("ALTER TABLE " + BUG_REPORTS_TABLE + " ADD COLUMN "
168                         + COLUMN_TYPE + " INTEGER DEFAULT " + MetaBugReport.TYPE_INTERACTIVE);
169             }
170             if (oldVersion < AUDIO_VERSION) {
171                 db.execSQL("ALTER TABLE " + BUG_REPORTS_TABLE + " ADD COLUMN "
172                         + COLUMN_BUGREPORT_FILENAME + " TEXT DEFAULT NULL");
173                 db.execSQL("ALTER TABLE " + BUG_REPORTS_TABLE + " ADD COLUMN "
174                         + COLUMN_AUDIO_FILENAME + " TEXT DEFAULT NULL");
175             }
176         }
177     }
178 
179     /**
180      * Builds an {@link Uri} that points to the single bug report and performs an action
181      * defined by given URI segment.
182      */
buildUriWithSegment(int bugReportId, @UriActionSegments String segment)183     static Uri buildUriWithSegment(int bugReportId, @UriActionSegments String segment) {
184         return Uri.parse("content://" + AUTHORITY + "/" + BUG_REPORTS_TABLE + "/"
185                 + segment + "/" + bugReportId);
186     }
187 
BugStorageProvider()188     public BugStorageProvider() {
189         mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
190         mUriMatcher.addURI(AUTHORITY, BUG_REPORTS_TABLE, URL_MATCHED_BUG_REPORTS_URI);
191         mUriMatcher.addURI(AUTHORITY, BUG_REPORTS_TABLE + "/#", URL_MATCHED_BUG_REPORT_ID_URI);
192         mUriMatcher.addURI(
193                 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_DELETE_FILES + "/#",
194                 URL_MATCHED_DELETE_FILES);
195         mUriMatcher.addURI(
196                 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_COMPLETE_DELETE + "/#",
197                 URL_MATCHED_COMPLETE_DELETE);
198         mUriMatcher.addURI(
199                 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_OPEN_BUGREPORT_FILE + "/#",
200                 URL_MATCHED_OPEN_BUGREPORT_FILE);
201         mUriMatcher.addURI(
202                 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_OPEN_AUDIO_FILE + "/#",
203                 URL_MATCHED_OPEN_AUDIO_FILE);
204         mUriMatcher.addURI(
205                 AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_OPEN_FILE + "/#",
206                 URL_MATCHED_OPEN_FILE);
207     }
208 
209     @Override
onCreate()210     public boolean onCreate() {
211         Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled.");
212 
213         mDatabaseHelper = new DatabaseHelper(getContext());
214         mConfig = new Config();
215         mConfig.start();
216         return true;
217     }
218 
219     @Override
query( @onNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder)220     public Cursor query(
221             @NonNull Uri uri,
222             @Nullable String[] projection,
223             @Nullable String selection,
224             @Nullable String[] selectionArgs,
225             @Nullable String sortOrder) {
226         return query(uri, projection, selection, selectionArgs, sortOrder, null);
227     }
228 
229     @Nullable
230     @Override
query( @onNull Uri uri, @Nullable String[] projection, @Nullable String selection, @Nullable String[] selectionArgs, @Nullable String sortOrder, @Nullable CancellationSignal cancellationSignal)231     public Cursor query(
232             @NonNull Uri uri,
233             @Nullable String[] projection,
234             @Nullable String selection,
235             @Nullable String[] selectionArgs,
236             @Nullable String sortOrder,
237             @Nullable CancellationSignal cancellationSignal) {
238         String table;
239         switch (mUriMatcher.match(uri)) {
240             // returns the list of bugreports that match the selection criteria.
241             case URL_MATCHED_BUG_REPORTS_URI:
242                 table = BUG_REPORTS_TABLE;
243                 break;
244             //  returns the bugreport that match the id.
245             case URL_MATCHED_BUG_REPORT_ID_URI:
246                 table = BUG_REPORTS_TABLE;
247                 if (selection != null || selectionArgs != null) {
248                     throw new IllegalArgumentException("selection is not allowed for "
249                             + URL_MATCHED_BUG_REPORT_ID_URI);
250                 }
251                 selection = COLUMN_ID + "=?";
252                 selectionArgs = new String[]{ uri.getLastPathSegment() };
253                 break;
254             default:
255                 throw new IllegalArgumentException("Unknown URL " + uri);
256         }
257         SQLiteDatabase db = mDatabaseHelper.getReadableDatabase();
258         Cursor cursor = db.query(false, table, null, selection, selectionArgs, null, null,
259                 sortOrder, null, cancellationSignal);
260         cursor.setNotificationUri(getContext().getContentResolver(), uri);
261         return cursor;
262     }
263 
264     @Nullable
265     @Override
insert(@onNull Uri uri, @Nullable ContentValues values)266     public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
267         String table;
268         if (values == null) {
269             throw new IllegalArgumentException("values cannot be null");
270         }
271         switch (mUriMatcher.match(uri)) {
272             case URL_MATCHED_BUG_REPORTS_URI:
273                 table = BUG_REPORTS_TABLE;
274                 break;
275             default:
276                 throw new IllegalArgumentException("unknown uri" + uri);
277         }
278         SQLiteDatabase db = mDatabaseHelper.getWritableDatabase();
279         long rowId = db.insert(table, null, values);
280         if (rowId > 0) {
281             Uri resultUri = Uri.parse("content://" + AUTHORITY + "/" + table + "/" + rowId);
282             // notify registered content observers
283             getContext().getContentResolver().notifyChange(resultUri, null);
284             return resultUri;
285         }
286         return null;
287     }
288 
289     @Nullable
290     @Override
getType(@onNull Uri uri)291     public String getType(@NonNull Uri uri) {
292         switch (mUriMatcher.match(uri)) {
293             case URL_MATCHED_OPEN_BUGREPORT_FILE:
294             case URL_MATCHED_OPEN_FILE:
295                 return "application/zip";
296             case URL_MATCHED_OPEN_AUDIO_FILE:
297                 return "audio/3gpp";
298             default:
299                 throw new IllegalArgumentException("unknown uri:" + uri);
300         }
301     }
302 
303     @Override
delete( @onNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs)304     public int delete(
305             @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
306         SQLiteDatabase db = mDatabaseHelper.getReadableDatabase();
307         switch (mUriMatcher.match(uri)) {
308             case URL_MATCHED_DELETE_FILES:
309                 if (selection != null || selectionArgs != null) {
310                     throw new IllegalArgumentException("selection is not allowed for "
311                             + URL_MATCHED_DELETE_FILES);
312                 }
313                 if (deleteFilesFor(getBugReportFromUri(uri))) {
314                     getContext().getContentResolver().notifyChange(uri, null);
315                     return 1;
316                 }
317                 return 0;
318             case URL_MATCHED_COMPLETE_DELETE:
319                 if (selection != null || selectionArgs != null) {
320                     throw new IllegalArgumentException("selection is not allowed for "
321                             + URL_MATCHED_COMPLETE_DELETE);
322                 }
323                 selection = COLUMN_ID + " = ?";
324                 selectionArgs = new String[]{uri.getLastPathSegment()};
325                 // Ignore the results of zip file deletion, possibly it wasn't even created.
326                 deleteFilesFor(getBugReportFromUri(uri));
327                 getContext().getContentResolver().notifyChange(uri, null);
328                 return db.delete(BUG_REPORTS_TABLE, selection, selectionArgs);
329             default:
330                 throw new IllegalArgumentException("Unknown URL " + uri);
331         }
332     }
333 
334     @Override
update( @onNull Uri uri, @Nullable ContentValues values, @Nullable String selection, @Nullable String[] selectionArgs)335     public int update(
336             @NonNull Uri uri,
337             @Nullable ContentValues values,
338             @Nullable String selection,
339             @Nullable String[] selectionArgs) {
340         if (values == null) {
341             throw new IllegalArgumentException("values cannot be null");
342         }
343         String table;
344         switch (mUriMatcher.match(uri)) {
345             case URL_MATCHED_BUG_REPORTS_URI:
346                 table = BUG_REPORTS_TABLE;
347                 break;
348             default:
349                 throw new IllegalArgumentException("Unknown URL " + uri);
350         }
351         SQLiteDatabase db = mDatabaseHelper.getWritableDatabase();
352         int rowCount = db.update(table, values, selection, selectionArgs);
353         if (rowCount > 0) {
354             // notify registered content observers
355             getContext().getContentResolver().notifyChange(uri, null);
356         }
357         Integer status = values.getAsInteger(COLUMN_STATUS);
358         // When the status is set to STATUS_UPLOAD_PENDING, we schedule an UploadJob under the
359         // current user, which is the primary user.
360         if (status != null && status.equals(Status.STATUS_UPLOAD_PENDING.getValue())) {
361             JobSchedulingUtils.scheduleUploadJob(BugStorageProvider.this.getContext());
362         }
363         return rowCount;
364     }
365 
366     /**
367      * This is called when a file is opened.
368      *
369      * <p>See {@link BugStorageUtils#openBugReportFileToWrite},
370      * {@link BugStorageUtils#openAudioMessageFileToWrite}.
371      */
372     @Nullable
373     @Override
openFile(@onNull Uri uri, @NonNull String mode)374     public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
375             throws FileNotFoundException {
376         Function<MetaBugReport, String> fileNameExtractor;
377         switch (mUriMatcher.match(uri)) {
378             case URL_MATCHED_OPEN_BUGREPORT_FILE:
379                 fileNameExtractor = MetaBugReport::getBugReportFileName;
380                 break;
381             case URL_MATCHED_OPEN_AUDIO_FILE:
382                 fileNameExtractor = MetaBugReport::getAudioFileName;
383                 break;
384             case URL_MATCHED_OPEN_FILE:
385                 File file = new File(getBugReportFromUri(uri).getFilePath());
386                 Log.v(TAG, "Opening file " + file + " with mode " + mode);
387                 return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode));
388             default:
389                 throw new IllegalArgumentException("unknown uri:" + uri);
390         }
391         // URI contains bugreport ID as the last segment, see the matched urls.
392         MetaBugReport bugReport = getBugReportFromUri(uri);
393         File file = new File(
394                 FileUtils.getPendingDir(getContext()), fileNameExtractor.apply(bugReport));
395         Log.v(TAG, "Opening file " + file + " with mode " + mode);
396         int modeBits = ParcelFileDescriptor.parseMode(mode);
397         return ParcelFileDescriptor.open(file, modeBits);
398     }
399 
getBugReportFromUri(@onNull Uri uri)400     private MetaBugReport getBugReportFromUri(@NonNull Uri uri) {
401         int bugreportId = Integer.parseInt(uri.getLastPathSegment());
402         return BugStorageUtils.findBugReport(getContext(), bugreportId)
403                 .orElseThrow(() -> new IllegalArgumentException("No record found for " + uri));
404     }
405 
406     /**
407      * Print the Provider's state into the given stream. This gets invoked if
408      * you run "dumpsys activity provider com.google.android.car.bugreport/.BugStorageProvider".
409      *
410      * @param fd The raw file descriptor that the dump is being sent to.
411      * @param writer The PrintWriter to which you should dump your state.  This will be
412      * closed for you after you return.
413      * @param args additional arguments to the dump request.
414      */
dump(FileDescriptor fd, PrintWriter writer, String[] args)415     public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
416         writer.println("BugStorageProvider:");
417         mConfig.dump(/* prefix= */ "  ", writer);
418     }
419 
deleteFilesFor(MetaBugReport bugReport)420     private boolean deleteFilesFor(MetaBugReport bugReport) {
421         if (!Strings.isNullOrEmpty(bugReport.getFilePath())) {
422             // Old bugreports have only filePath.
423             return new File(bugReport.getFilePath()).delete();
424         }
425         File pendingDir = FileUtils.getPendingDir(getContext());
426         boolean result = true;
427         if (!Strings.isNullOrEmpty(bugReport.getAudioFileName())) {
428             result = new File(pendingDir, bugReport.getAudioFileName()).delete();
429         }
430         if (!Strings.isNullOrEmpty(bugReport.getBugReportFileName())) {
431             result = result && new File(pendingDir, bugReport.getBugReportFileName()).delete();
432         }
433         return result;
434     }
435 }
436