blob: d9e271e526ae7fe2d817f18f7e9f3a193607157b [file] [log] [blame]
/*
* Copyright (C) 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.car.bugreport;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.annotation.StringDef;
import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.content.UriMatcher;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;
import android.os.CancellationSignal;
import android.os.ParcelFileDescriptor;
import android.util.Log;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import java.io.File;
import java.io.FileDescriptor;
import java.io.FileNotFoundException;
import java.io.PrintWriter;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.function.Function;
/**
* Provides a bug storage interface to save and upload bugreports filed from all users.
* In Android Automotive user 0 runs as the system and all the time, while other users won't once
* their session ends. This content provider enables bug reports to be uploaded even after
* user session ends.
*
* <p>A bugreport constists of two files: bugreport zip file and audio file. Audio file is added
* later through notification. {@link SimpleUploaderAsyncTask} merges two files into one zip file
* before uploading.
*
* <p>All files are stored under system user's {@link FileUtils#getPendingDir}.
*/
public class BugStorageProvider extends ContentProvider {
private static final String TAG = BugStorageProvider.class.getSimpleName();
private static final String AUTHORITY = "com.google.android.car.bugreport";
private static final String BUG_REPORTS_TABLE = "bugreports";
/** Deletes files associated with a bug report. */
static final String URL_SEGMENT_DELETE_FILES = "deleteZipFile";
/** Destructively deletes a bug report. */
static final String URL_SEGMENT_COMPLETE_DELETE = "completeDelete";
/** Opens bugreport file of a bug report, uses column {@link #COLUMN_BUGREPORT_FILENAME}. */
static final String URL_SEGMENT_OPEN_BUGREPORT_FILE = "openBugReportFile";
/** Opens audio file of a bug report, uses column {@link #URL_MATCHED_OPEN_AUDIO_FILE}. */
static final String URL_SEGMENT_OPEN_AUDIO_FILE = "openAudioFile";
/**
* Opens final bugreport zip file, uses column {@link #COLUMN_FILEPATH}.
*
* <p>NOTE: This is the old way of storing final zipped bugreport. In
* {@code BugStorageProvider#AUDIO_VERSION} {@link #COLUMN_FILEPATH} is dropped. But there are
* still some devices with this field set.
*/
static final String URL_SEGMENT_OPEN_FILE = "openFile";
// URL Matcher IDs.
private static final int URL_MATCHED_BUG_REPORTS_URI = 1;
private static final int URL_MATCHED_BUG_REPORT_ID_URI = 2;
private static final int URL_MATCHED_DELETE_FILES = 3;
private static final int URL_MATCHED_COMPLETE_DELETE = 4;
private static final int URL_MATCHED_OPEN_BUGREPORT_FILE = 5;
private static final int URL_MATCHED_OPEN_AUDIO_FILE = 6;
private static final int URL_MATCHED_OPEN_FILE = 7;
@StringDef({
URL_SEGMENT_DELETE_FILES,
URL_SEGMENT_COMPLETE_DELETE,
URL_SEGMENT_OPEN_BUGREPORT_FILE,
URL_SEGMENT_OPEN_AUDIO_FILE,
URL_SEGMENT_OPEN_FILE,
})
@Retention(RetentionPolicy.SOURCE)
@interface UriActionSegments {}
static final Uri BUGREPORT_CONTENT_URI =
Uri.parse("content://" + AUTHORITY + "/" + BUG_REPORTS_TABLE);
/** See {@link MetaBugReport} for column descriptions. */
static final String COLUMN_ID = "_ID";
static final String COLUMN_USERNAME = "username";
static final String COLUMN_TITLE = "title";
static final String COLUMN_TIMESTAMP = "timestamp";
/** not used anymore */
static final String COLUMN_DESCRIPTION = "description";
/** not used anymore, but some devices still might have bugreports with this field set. */
static final String COLUMN_FILEPATH = "filepath";
static final String COLUMN_STATUS = "status";
static final String COLUMN_STATUS_MESSAGE = "message";
static final String COLUMN_TYPE = "type";
static final String COLUMN_BUGREPORT_FILENAME = "bugreport_filename";
static final String COLUMN_AUDIO_FILENAME = "audio_filename";
private DatabaseHelper mDatabaseHelper;
private final UriMatcher mUriMatcher;
private Config mConfig;
/**
* A helper class to work with sqlite database.
*/
private static class DatabaseHelper extends SQLiteOpenHelper {
private static final String TAG = DatabaseHelper.class.getSimpleName();
private static final String DATABASE_NAME = "bugreport.db";
/**
* All changes in database versions should be recorded here.
* 1: Initial version.
* 2: Add integer column details_needed.
* 3: Add string column audio_filename and bugreport_filename.
*/
private static final int INITIAL_VERSION = 1;
private static final int TYPE_VERSION = 2;
private static final int AUDIO_VERSION = 3;
private static final int DATABASE_VERSION = AUDIO_VERSION;
private static final String CREATE_TABLE = "CREATE TABLE " + BUG_REPORTS_TABLE + " ("
+ COLUMN_ID + " INTEGER PRIMARY KEY,"
+ COLUMN_USERNAME + " TEXT,"
+ COLUMN_TITLE + " TEXT,"
+ COLUMN_TIMESTAMP + " TEXT NOT NULL,"
+ COLUMN_DESCRIPTION + " TEXT NULL,"
+ COLUMN_FILEPATH + " TEXT DEFAULT NULL,"
+ COLUMN_STATUS + " INTEGER DEFAULT " + Status.STATUS_WRITE_PENDING.getValue() + ","
+ COLUMN_STATUS_MESSAGE + " TEXT NULL,"
+ COLUMN_TYPE + " INTEGER DEFAULT " + MetaBugReport.TYPE_INTERACTIVE + ","
+ COLUMN_BUGREPORT_FILENAME + " TEXT DEFAULT NULL,"
+ COLUMN_AUDIO_FILENAME + " TEXT DEFAULT NULL"
+ ");";
DatabaseHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
db.execSQL(CREATE_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
Log.w(TAG, "Upgrading from " + oldVersion + " to " + newVersion);
if (oldVersion < TYPE_VERSION) {
db.execSQL("ALTER TABLE " + BUG_REPORTS_TABLE + " ADD COLUMN "
+ COLUMN_TYPE + " INTEGER DEFAULT " + MetaBugReport.TYPE_INTERACTIVE);
}
if (oldVersion < AUDIO_VERSION) {
db.execSQL("ALTER TABLE " + BUG_REPORTS_TABLE + " ADD COLUMN "
+ COLUMN_BUGREPORT_FILENAME + " TEXT DEFAULT NULL");
db.execSQL("ALTER TABLE " + BUG_REPORTS_TABLE + " ADD COLUMN "
+ COLUMN_AUDIO_FILENAME + " TEXT DEFAULT NULL");
}
}
}
/**
* Builds an {@link Uri} that points to the single bug report and performs an action
* defined by given URI segment.
*/
static Uri buildUriWithSegment(int bugReportId, @UriActionSegments String segment) {
return Uri.parse("content://" + AUTHORITY + "/" + BUG_REPORTS_TABLE + "/"
+ segment + "/" + bugReportId);
}
public BugStorageProvider() {
mUriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
mUriMatcher.addURI(AUTHORITY, BUG_REPORTS_TABLE, URL_MATCHED_BUG_REPORTS_URI);
mUriMatcher.addURI(AUTHORITY, BUG_REPORTS_TABLE + "/#", URL_MATCHED_BUG_REPORT_ID_URI);
mUriMatcher.addURI(
AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_DELETE_FILES + "/#",
URL_MATCHED_DELETE_FILES);
mUriMatcher.addURI(
AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_COMPLETE_DELETE + "/#",
URL_MATCHED_COMPLETE_DELETE);
mUriMatcher.addURI(
AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_OPEN_BUGREPORT_FILE + "/#",
URL_MATCHED_OPEN_BUGREPORT_FILE);
mUriMatcher.addURI(
AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_OPEN_AUDIO_FILE + "/#",
URL_MATCHED_OPEN_AUDIO_FILE);
mUriMatcher.addURI(
AUTHORITY, BUG_REPORTS_TABLE + "/" + URL_SEGMENT_OPEN_FILE + "/#",
URL_MATCHED_OPEN_FILE);
}
@Override
public boolean onCreate() {
Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled.");
mDatabaseHelper = new DatabaseHelper(getContext());
mConfig = new Config();
mConfig.start();
return true;
}
@Override
public Cursor query(
@NonNull Uri uri,
@Nullable String[] projection,
@Nullable String selection,
@Nullable String[] selectionArgs,
@Nullable String sortOrder) {
return query(uri, projection, selection, selectionArgs, sortOrder, null);
}
@Nullable
@Override
public Cursor query(
@NonNull Uri uri,
@Nullable String[] projection,
@Nullable String selection,
@Nullable String[] selectionArgs,
@Nullable String sortOrder,
@Nullable CancellationSignal cancellationSignal) {
String table;
switch (mUriMatcher.match(uri)) {
// returns the list of bugreports that match the selection criteria.
case URL_MATCHED_BUG_REPORTS_URI:
table = BUG_REPORTS_TABLE;
break;
// returns the bugreport that match the id.
case URL_MATCHED_BUG_REPORT_ID_URI:
table = BUG_REPORTS_TABLE;
if (selection != null || selectionArgs != null) {
throw new IllegalArgumentException("selection is not allowed for "
+ URL_MATCHED_BUG_REPORT_ID_URI);
}
selection = COLUMN_ID + "=?";
selectionArgs = new String[]{ uri.getLastPathSegment() };
break;
default:
throw new IllegalArgumentException("Unknown URL " + uri);
}
SQLiteDatabase db = mDatabaseHelper.getReadableDatabase();
Cursor cursor = db.query(false, table, null, selection, selectionArgs, null, null,
sortOrder, null, cancellationSignal);
cursor.setNotificationUri(getContext().getContentResolver(), uri);
return cursor;
}
@Nullable
@Override
public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
String table;
if (values == null) {
throw new IllegalArgumentException("values cannot be null");
}
switch (mUriMatcher.match(uri)) {
case URL_MATCHED_BUG_REPORTS_URI:
table = BUG_REPORTS_TABLE;
break;
default:
throw new IllegalArgumentException("unknown uri" + uri);
}
SQLiteDatabase db = mDatabaseHelper.getWritableDatabase();
long rowId = db.insert(table, null, values);
if (rowId > 0) {
Uri resultUri = Uri.parse("content://" + AUTHORITY + "/" + table + "/" + rowId);
// notify registered content observers
getContext().getContentResolver().notifyChange(resultUri, null);
return resultUri;
}
return null;
}
@Nullable
@Override
public String getType(@NonNull Uri uri) {
switch (mUriMatcher.match(uri)) {
case URL_MATCHED_OPEN_BUGREPORT_FILE:
case URL_MATCHED_OPEN_FILE:
return "application/zip";
case URL_MATCHED_OPEN_AUDIO_FILE:
return "audio/3gpp";
default:
throw new IllegalArgumentException("unknown uri:" + uri);
}
}
@Override
public int delete(
@NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
SQLiteDatabase db = mDatabaseHelper.getReadableDatabase();
switch (mUriMatcher.match(uri)) {
case URL_MATCHED_DELETE_FILES:
if (selection != null || selectionArgs != null) {
throw new IllegalArgumentException("selection is not allowed for "
+ URL_MATCHED_DELETE_FILES);
}
if (deleteFilesFor(getBugReportFromUri(uri))) {
getContext().getContentResolver().notifyChange(uri, null);
return 1;
}
return 0;
case URL_MATCHED_COMPLETE_DELETE:
if (selection != null || selectionArgs != null) {
throw new IllegalArgumentException("selection is not allowed for "
+ URL_MATCHED_COMPLETE_DELETE);
}
selection = COLUMN_ID + " = ?";
selectionArgs = new String[]{uri.getLastPathSegment()};
// Ignore the results of zip file deletion, possibly it wasn't even created.
deleteFilesFor(getBugReportFromUri(uri));
getContext().getContentResolver().notifyChange(uri, null);
return db.delete(BUG_REPORTS_TABLE, selection, selectionArgs);
default:
throw new IllegalArgumentException("Unknown URL " + uri);
}
}
@Override
public int update(
@NonNull Uri uri,
@Nullable ContentValues values,
@Nullable String selection,
@Nullable String[] selectionArgs) {
if (values == null) {
throw new IllegalArgumentException("values cannot be null");
}
String table;
switch (mUriMatcher.match(uri)) {
case URL_MATCHED_BUG_REPORTS_URI:
table = BUG_REPORTS_TABLE;
break;
default:
throw new IllegalArgumentException("Unknown URL " + uri);
}
SQLiteDatabase db = mDatabaseHelper.getWritableDatabase();
int rowCount = db.update(table, values, selection, selectionArgs);
if (rowCount > 0) {
// notify registered content observers
getContext().getContentResolver().notifyChange(uri, null);
}
Integer status = values.getAsInteger(COLUMN_STATUS);
// When the status is set to STATUS_UPLOAD_PENDING, we schedule an UploadJob under the
// current user, which is the primary user.
if (status != null && status.equals(Status.STATUS_UPLOAD_PENDING.getValue())) {
JobSchedulingUtils.scheduleUploadJob(BugStorageProvider.this.getContext());
}
return rowCount;
}
/**
* This is called when a file is opened.
*
* <p>See {@link BugStorageUtils#openBugReportFileToWrite},
* {@link BugStorageUtils#openAudioMessageFileToWrite}.
*/
@Nullable
@Override
public ParcelFileDescriptor openFile(@NonNull Uri uri, @NonNull String mode)
throws FileNotFoundException {
Function<MetaBugReport, String> fileNameExtractor;
switch (mUriMatcher.match(uri)) {
case URL_MATCHED_OPEN_BUGREPORT_FILE:
fileNameExtractor = MetaBugReport::getBugReportFileName;
break;
case URL_MATCHED_OPEN_AUDIO_FILE:
fileNameExtractor = MetaBugReport::getAudioFileName;
break;
case URL_MATCHED_OPEN_FILE:
File file = new File(getBugReportFromUri(uri).getFilePath());
Log.v(TAG, "Opening file " + file + " with mode " + mode);
return ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode));
default:
throw new IllegalArgumentException("unknown uri:" + uri);
}
// URI contains bugreport ID as the last segment, see the matched urls.
MetaBugReport bugReport = getBugReportFromUri(uri);
File file = new File(
FileUtils.getPendingDir(getContext()), fileNameExtractor.apply(bugReport));
Log.v(TAG, "Opening file " + file + " with mode " + mode);
int modeBits = ParcelFileDescriptor.parseMode(mode);
return ParcelFileDescriptor.open(file, modeBits);
}
private MetaBugReport getBugReportFromUri(@NonNull Uri uri) {
int bugreportId = Integer.parseInt(uri.getLastPathSegment());
return BugStorageUtils.findBugReport(getContext(), bugreportId)
.orElseThrow(() -> new IllegalArgumentException("No record found for " + uri));
}
/**
* Print the Provider's state into the given stream. This gets invoked if
* you run "dumpsys activity provider com.google.android.car.bugreport/.BugStorageProvider".
*
* @param fd The raw file descriptor that the dump is being sent to.
* @param writer The PrintWriter to which you should dump your state. This will be
* closed for you after you return.
* @param args additional arguments to the dump request.
*/
public void dump(FileDescriptor fd, PrintWriter writer, String[] args) {
writer.println("BugStorageProvider:");
mConfig.dump(/* prefix= */ " ", writer);
}
private boolean deleteFilesFor(MetaBugReport bugReport) {
if (!Strings.isNullOrEmpty(bugReport.getFilePath())) {
// Old bugreports have only filePath.
return new File(bugReport.getFilePath()).delete();
}
File pendingDir = FileUtils.getPendingDir(getContext());
boolean result = true;
if (!Strings.isNullOrEmpty(bugReport.getAudioFileName())) {
result = new File(pendingDir, bugReport.getAudioFileName()).delete();
}
if (!Strings.isNullOrEmpty(bugReport.getBugReportFileName())) {
result = result && new File(pendingDir, bugReport.getBugReportFileName()).delete();
}
return result;
}
}