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