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