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