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