1 /* 2 * Copyright (C) 2021 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 static com.android.car.bugreport.BugStorageProvider.COLUMN_AUDIO_FILENAME; 19 import static com.android.car.bugreport.BugStorageProvider.COLUMN_BUGREPORT_FILENAME; 20 import static com.android.car.bugreport.BugStorageProvider.COLUMN_FILEPATH; 21 import static com.android.car.bugreport.BugStorageProvider.COLUMN_ID; 22 import static com.android.car.bugreport.BugStorageProvider.COLUMN_STATUS; 23 import static com.android.car.bugreport.BugStorageProvider.COLUMN_STATUS_MESSAGE; 24 import static com.android.car.bugreport.BugStorageProvider.COLUMN_TIMESTAMP; 25 import static com.android.car.bugreport.BugStorageProvider.COLUMN_TITLE; 26 import static com.android.car.bugreport.BugStorageProvider.COLUMN_TTL_POINTS; 27 import static com.android.car.bugreport.BugStorageProvider.COLUMN_TYPE; 28 import static com.android.car.bugreport.BugStorageProvider.COLUMN_USERNAME; 29 30 import android.content.ContentResolver; 31 import android.content.ContentValues; 32 import android.content.Context; 33 import android.database.Cursor; 34 import android.net.Uri; 35 import android.util.Log; 36 37 import androidx.annotation.NonNull; 38 import androidx.annotation.Nullable; 39 40 import com.google.api.client.auth.oauth2.TokenResponseException; 41 import com.google.common.base.Preconditions; 42 import com.google.common.base.Strings; 43 import com.google.common.collect.ImmutableList; 44 45 import java.io.FileNotFoundException; 46 import java.io.InputStream; 47 import java.io.OutputStream; 48 import java.text.DateFormat; 49 import java.text.SimpleDateFormat; 50 import java.util.ArrayList; 51 import java.util.Date; 52 import java.util.List; 53 import java.util.Objects; 54 import java.util.Optional; 55 56 /** 57 * A class that hides details when communicating with the bug storage provider. 58 */ 59 final class BugStorageUtils { 60 private static final String TAG = BugStorageUtils.class.getSimpleName(); 61 62 /** 63 * When time/time-zone set incorrectly, Google API returns "400: invalid_grant" error with 64 * description containing this text. 65 */ 66 private static final String CLOCK_SKEW_ERROR = "clock with skew to account"; 67 68 /** When time/time-zone set incorrectly, Google API returns this error. */ 69 private static final String INVALID_GRANT = "invalid_grant"; 70 71 private static final DateFormat TIMESTAMP_FORMAT = new SimpleDateFormat("yyyy-MM-dd_HH-mm-ss"); 72 73 /** 74 * List of {@link Status}-es that will be expired after certain period by 75 * {@link ExpireOldBugReportsJob}. 76 */ 77 private static final ImmutableList<Integer> EXPIRATION_STATUSES = ImmutableList.of( 78 Status.STATUS_WRITE_FAILED.getValue(), 79 Status.STATUS_UPLOAD_PENDING.getValue(), 80 Status.STATUS_UPLOAD_FAILED.getValue(), 81 Status.STATUS_PENDING_USER_ACTION.getValue(), 82 Status.STATUS_MOVE_FAILED.getValue(), 83 Status.STATUS_MOVE_IN_PROGRESS.getValue(), 84 Status.STATUS_AUDIO_PENDING.getValue(), 85 Status.STATUS_UPLOADED_BEFORE.getValue()); 86 87 /** 88 * Creates a new {@link Status#STATUS_WRITE_PENDING} bug report record in a local sqlite 89 * database. 90 * 91 * @param context - an application context. 92 * @param title - title of the bug report. 93 * @param timestamp - timestamp when the bug report was initiated. 94 * @param username - current user name. Note, it's a user name, not an account name. 95 * @param type - bug report type, {@link MetaBugReport.BugReportType}. 96 * @return an instance of {@link MetaBugReport} that was created in a database. 97 */ 98 @NonNull createBugReport( @onNull Context context, @NonNull String title, @NonNull String timestamp, @NonNull String username, @MetaBugReport.BugReportType int type)99 static MetaBugReport createBugReport( 100 @NonNull Context context, 101 @NonNull String title, 102 @NonNull String timestamp, 103 @NonNull String username, 104 @MetaBugReport.BugReportType int type) { 105 // insert bug report username and title 106 ContentValues values = new ContentValues(); 107 values.put(COLUMN_TITLE, title); 108 values.put(COLUMN_TIMESTAMP, timestamp); 109 values.put(COLUMN_USERNAME, username); 110 values.put(COLUMN_TYPE, type); 111 112 ContentResolver r = context.getContentResolver(); 113 Uri uri = r.insert(BugStorageProvider.BUGREPORT_CONTENT_URI, values); 114 return findBugReport(context, Integer.parseInt(uri.getLastPathSegment())).get(); 115 } 116 117 /** Returns an output stream to write the zipped file to. */ 118 @NonNull openBugReportFileToWrite( @onNull Context context, @NonNull MetaBugReport metaBugReport)119 static OutputStream openBugReportFileToWrite( 120 @NonNull Context context, @NonNull MetaBugReport metaBugReport) 121 throws FileNotFoundException { 122 ContentResolver r = context.getContentResolver(); 123 return r.openOutputStream(BugStorageProvider.buildUriWithSegment( 124 metaBugReport.getId(), BugStorageProvider.URL_SEGMENT_OPEN_BUGREPORT_FILE), "wt"); 125 } 126 127 /** Returns an output stream to write the audio message file to. */ openAudioMessageFileToWrite( @onNull Context context, @NonNull MetaBugReport metaBugReport)128 static OutputStream openAudioMessageFileToWrite( 129 @NonNull Context context, @NonNull MetaBugReport metaBugReport) 130 throws FileNotFoundException { 131 ContentResolver r = context.getContentResolver(); 132 return r.openOutputStream(BugStorageProvider.buildUriWithSegment( 133 metaBugReport.getId(), BugStorageProvider.URL_SEGMENT_OPEN_AUDIO_FILE), "wt"); 134 } 135 136 /** 137 * Returns an input stream to read the final zip file from. 138 * 139 * <p>NOTE: This is the old way of storing final zipped bugreport. See 140 * {@link BugStorageProvider#URL_SEGMENT_OPEN_FILE} for more info. 141 */ openFileToRead(Context context, MetaBugReport bug)142 static InputStream openFileToRead(Context context, MetaBugReport bug) 143 throws FileNotFoundException { 144 return context.getContentResolver().openInputStream( 145 BugStorageProvider.buildUriWithSegment( 146 bug.getId(), BugStorageProvider.URL_SEGMENT_OPEN_FILE)); 147 } 148 149 /** Returns an input stream to read the bug report zip file from. */ openBugReportFileToRead(Context context, MetaBugReport bug)150 static InputStream openBugReportFileToRead(Context context, MetaBugReport bug) 151 throws FileNotFoundException { 152 return context.getContentResolver().openInputStream( 153 BugStorageProvider.buildUriWithSegment( 154 bug.getId(), BugStorageProvider.URL_SEGMENT_OPEN_BUGREPORT_FILE)); 155 } 156 157 /** Returns an input stream to read the audio file from. */ openAudioFileToRead(Context context, MetaBugReport bug)158 static InputStream openAudioFileToRead(Context context, MetaBugReport bug) 159 throws FileNotFoundException { 160 return context.getContentResolver().openInputStream( 161 BugStorageProvider.buildUriWithSegment( 162 bug.getId(), BugStorageProvider.URL_SEGMENT_OPEN_AUDIO_FILE)); 163 } 164 165 /** 166 * Deletes {@link MetaBugReport} record from a local database and deletes the associated file. 167 * 168 * <p>WARNING: destructive operation. 169 * 170 * @param context - an application context. 171 * @param bugReportId - a bug report id. 172 * @return true if the record was deleted. 173 */ completeDeleteBugReport(@onNull Context context, int bugReportId)174 static boolean completeDeleteBugReport(@NonNull Context context, int bugReportId) { 175 ContentResolver r = context.getContentResolver(); 176 return r.delete(BugStorageProvider.buildUriWithSegment( 177 bugReportId, BugStorageProvider.URL_SEGMENT_COMPLETE_DELETE), null, null) == 1; 178 } 179 180 /** Deletes all files for given bugreport id; doesn't delete sqlite3 record. */ deleteBugReportFiles(@onNull Context context, int bugReportId)181 static boolean deleteBugReportFiles(@NonNull Context context, int bugReportId) { 182 ContentResolver r = context.getContentResolver(); 183 return r.delete(BugStorageProvider.buildUriWithSegment( 184 bugReportId, BugStorageProvider.URL_SEGMENT_DELETE_FILES), null, null) == 1; 185 } 186 187 /** 188 * Deletes the associated zip file from disk and then sets status {@link Status#STATUS_EXPIRED}. 189 * 190 * @return true if succeeded. 191 */ expireBugReport(@onNull Context context, int bugReportId)192 static boolean expireBugReport(@NonNull Context context, int bugReportId) { 193 ContentResolver r = context.getContentResolver(); 194 return r.delete(BugStorageProvider.buildUriWithSegment( 195 bugReportId, BugStorageProvider.URL_SEGMENT_EXPIRE), null, null) == 1; 196 } 197 198 /** 199 * Returns all the bugreports that are waiting to be uploaded. 200 */ 201 @NonNull getUploadPendingBugReports(@onNull Context context)202 public static List<MetaBugReport> getUploadPendingBugReports(@NonNull Context context) { 203 String selection = COLUMN_STATUS + "=?"; 204 String[] selectionArgs = new String[]{ 205 Integer.toString(Status.STATUS_UPLOAD_PENDING.getValue())}; 206 return getBugreports(context, selection, selectionArgs, null); 207 } 208 209 /** 210 * Returns all bugreports in descending order by the ID field. ID is the index in the 211 * database. 212 */ 213 @NonNull getAllBugReportsDescending(@onNull Context context)214 public static List<MetaBugReport> getAllBugReportsDescending(@NonNull Context context) { 215 return getBugreports(context, null, null, COLUMN_ID + " DESC"); 216 } 217 218 /** 219 * Returns list of bugreports with zip files (with the best possible guess). 220 * 221 * @param context A context. 222 * @param ttlPointsReachedZero if true it returns bugreports with 223 * {@link BugStorageProvider#COLUMN_TTL_POINTS} equal 0; if false 224 * {@link BugStorageProvider#COLUMN_TTL_POINTS} more than 0. 225 */ 226 @NonNull getUnexpiredBugReportsWithZipFile( @onNull Context context, boolean ttlPointsReachedZero)227 static List<MetaBugReport> getUnexpiredBugReportsWithZipFile( 228 @NonNull Context context, boolean ttlPointsReachedZero) { 229 // Number of question marks should be the same as the size of EXPIRATION_STATUSES. 230 String selection = COLUMN_STATUS + " IN (?, ?, ?, ?, ?, ?, ?, ?)"; 231 Preconditions.checkState(EXPIRATION_STATUSES.size() == 8, "Invalid EXPIRATION_STATUSES"); 232 if (ttlPointsReachedZero) { 233 selection += " AND " + COLUMN_TTL_POINTS + " = 0"; 234 } else { 235 selection += " AND " + COLUMN_TTL_POINTS + " > 0"; 236 } 237 String[] selectionArgs = EXPIRATION_STATUSES.stream() 238 .map(i -> Integer.toString(i)).toArray(String[]::new); 239 return getBugreports(context, selection, selectionArgs, null); 240 } 241 242 /** Return true if bugreport with given status can be expired. */ canBugReportBeExpired(int status)243 static boolean canBugReportBeExpired(int status) { 244 return EXPIRATION_STATUSES.contains(status); 245 } 246 247 /** Returns {@link MetaBugReport} for given bugreport id. */ findBugReport(Context context, int bugreportId)248 static Optional<MetaBugReport> findBugReport(Context context, int bugreportId) { 249 String selection = COLUMN_ID + " = ?"; 250 String[] selectionArgs = new String[]{Integer.toString(bugreportId)}; 251 List<MetaBugReport> bugs = BugStorageUtils.getBugreports( 252 context, selection, selectionArgs, null); 253 if (bugs.isEmpty()) { 254 return Optional.empty(); 255 } 256 return Optional.of(bugs.get(0)); 257 } 258 getBugreports( Context context, String selection, String[] selectionArgs, String order)259 private static List<MetaBugReport> getBugreports( 260 Context context, String selection, String[] selectionArgs, String order) { 261 ArrayList<MetaBugReport> bugReports = new ArrayList<>(); 262 String[] projection = { 263 COLUMN_ID, 264 COLUMN_USERNAME, 265 COLUMN_TITLE, 266 COLUMN_TIMESTAMP, 267 COLUMN_BUGREPORT_FILENAME, 268 COLUMN_AUDIO_FILENAME, 269 COLUMN_FILEPATH, 270 COLUMN_STATUS, 271 COLUMN_STATUS_MESSAGE, 272 COLUMN_TYPE, 273 COLUMN_TTL_POINTS}; 274 ContentResolver r = context.getContentResolver(); 275 Cursor c = r.query(BugStorageProvider.BUGREPORT_CONTENT_URI, projection, 276 selection, selectionArgs, order); 277 278 int count = (c != null) ? c.getCount() : 0; 279 280 if (count > 0) c.moveToFirst(); 281 for (int i = 0; i < count; i++) { 282 MetaBugReport meta = MetaBugReport.builder() 283 .setId(getInt(c, COLUMN_ID)) 284 .setTimestamp(getString(c, COLUMN_TIMESTAMP)) 285 .setUserName(getString(c, COLUMN_USERNAME)) 286 .setTitle(getString(c, COLUMN_TITLE)) 287 .setBugReportFileName(getString(c, COLUMN_BUGREPORT_FILENAME)) 288 .setAudioFileName(getString(c, COLUMN_AUDIO_FILENAME)) 289 .setFilePath(getString(c, COLUMN_FILEPATH)) 290 .setStatus(getInt(c, COLUMN_STATUS)) 291 .setStatusMessage(getString(c, COLUMN_STATUS_MESSAGE)) 292 .setType(getInt(c, COLUMN_TYPE)) 293 .setTtlPoints(getInt(c, COLUMN_TTL_POINTS)) 294 .build(); 295 bugReports.add(meta); 296 c.moveToNext(); 297 } 298 if (c != null) c.close(); 299 return bugReports; 300 } 301 302 /** 303 * returns 0 if the column is not found. Otherwise returns the column value. 304 */ getInt(Cursor c, String colName)305 private static int getInt(Cursor c, String colName) { 306 int colIndex = c.getColumnIndex(colName); 307 if (colIndex == -1) { 308 Log.w(TAG, "Column " + colName + " not found."); 309 return 0; 310 } 311 return c.getInt(colIndex); 312 } 313 314 /** 315 * Returns the column value. If the column is not found returns empty string. 316 */ getString(Cursor c, String colName)317 private static String getString(Cursor c, String colName) { 318 int colIndex = c.getColumnIndex(colName); 319 if (colIndex == -1) { 320 Log.w(TAG, "Column " + colName + " not found."); 321 return ""; 322 } 323 return Strings.nullToEmpty(c.getString(colIndex)); 324 } 325 326 /** 327 * Sets bugreport status to uploaded successfully. 328 */ setUploadSuccess(Context context, MetaBugReport bugReport)329 public static void setUploadSuccess(Context context, MetaBugReport bugReport) { 330 setBugReportStatus(context, bugReport, Status.STATUS_UPLOAD_SUCCESS, 331 "Upload time: " + currentTimestamp()); 332 } 333 334 /** 335 * Sets bugreport status to uploaded before. 336 */ setUploadedBefore(Context context, MetaBugReport bugReport, Exception e)337 public static void setUploadedBefore(Context context, MetaBugReport bugReport, Exception e) { 338 setBugReportStatus(context, bugReport, Status.STATUS_UPLOADED_BEFORE, 339 "Already uploaded, new attempt failed: " + getRootCauseMessage(e)); 340 } 341 342 /** 343 * Sets bugreport status pending, and update the message to last exception message. 344 * 345 * <p>Used when a transient error has occurred. 346 */ setUploadRetry(Context context, MetaBugReport bugReport, Exception e)347 public static void setUploadRetry(Context context, MetaBugReport bugReport, Exception e) { 348 setBugReportStatus(context, bugReport, Status.STATUS_UPLOAD_PENDING, 349 getRootCauseMessage(e)); 350 } 351 352 /** 353 * Sets bugreport status pending and update the message to last message. 354 * 355 * <p>Used when a transient error has occurred. 356 */ setUploadRetry(Context context, MetaBugReport bugReport, String msg)357 public static void setUploadRetry(Context context, MetaBugReport bugReport, String msg) { 358 setBugReportStatus(context, bugReport, Status.STATUS_UPLOAD_PENDING, msg); 359 } 360 361 /** Gets the root cause of the error. */ 362 @NonNull getRootCauseMessage(@ullable Throwable t)363 private static String getRootCauseMessage(@Nullable Throwable t) { 364 if (t == null) { 365 return "No error"; 366 } else if (t instanceof TokenResponseException) { 367 TokenResponseException ex = (TokenResponseException) t; 368 if (Objects.equals(ex.getDetails().getError(), INVALID_GRANT) 369 && ex.getDetails().getErrorDescription().contains(CLOCK_SKEW_ERROR)) { 370 return "Auth error. Check if time & time-zone is correct."; 371 } 372 } 373 while (t.getCause() != null) t = t.getCause(); 374 return t.getMessage(); 375 } 376 377 /** 378 * Updates bug report record status. 379 * 380 * <p>NOTE: When status is set to STATUS_UPLOAD_PENDING, BugStorageProvider automatically 381 * schedules the bugreport to be uploaded. 382 * 383 * @return Updated {@link MetaBugReport}. 384 */ setBugReportStatus( Context context, MetaBugReport bugReport, Status status, String message)385 static MetaBugReport setBugReportStatus( 386 Context context, MetaBugReport bugReport, Status status, String message) { 387 return update(context, bugReport.toBuilder() 388 .setStatus(status.getValue()) 389 .setStatusMessage(message) 390 .build()); 391 } 392 393 /** 394 * Updates bug report record status. 395 * 396 * <p>NOTE: When status is set to STATUS_UPLOAD_PENDING, BugStorageProvider automatically 397 * schedules the bugreport to be uploaded. 398 * 399 * @return Updated {@link MetaBugReport}. 400 */ setBugReportStatus( Context context, MetaBugReport bugReport, Status status, Exception e)401 static MetaBugReport setBugReportStatus( 402 Context context, MetaBugReport bugReport, Status status, Exception e) { 403 return setBugReportStatus(context, bugReport, status, getRootCauseMessage(e)); 404 } 405 406 /** 407 * Updates the bugreport and returns the updated version. 408 * 409 * <p>NOTE: doesn't update all the fields. 410 */ update(Context context, MetaBugReport bugReport)411 static MetaBugReport update(Context context, MetaBugReport bugReport) { 412 // Update only necessary fields. 413 ContentValues values = new ContentValues(); 414 values.put(COLUMN_BUGREPORT_FILENAME, bugReport.getBugReportFileName()); 415 values.put(COLUMN_AUDIO_FILENAME, bugReport.getAudioFileName()); 416 values.put(COLUMN_STATUS, bugReport.getStatus()); 417 values.put(COLUMN_STATUS_MESSAGE, bugReport.getStatusMessage()); 418 String where = COLUMN_ID + "=" + bugReport.getId(); 419 context.getContentResolver().update( 420 BugStorageProvider.BUGREPORT_CONTENT_URI, values, where, null); 421 return findBugReport(context, bugReport.getId()).orElseThrow( 422 () -> new IllegalArgumentException("Bug " + bugReport.getId() + " not found")); 423 } 424 currentTimestamp()425 private static String currentTimestamp() { 426 return TIMESTAMP_FORMAT.format(new Date()); 427 } 428 } 429