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.android.car.bugreport;
17 
18 import android.annotation.NonNull;
19 import android.content.Context;
20 import android.os.AsyncTask;
21 import android.text.TextUtils;
22 import android.util.Log;
23 
24 import com.google.api.client.extensions.android.http.AndroidHttp;
25 import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
26 import com.google.api.client.http.HttpTransport;
27 import com.google.api.client.http.InputStreamContent;
28 import com.google.api.client.json.JsonFactory;
29 import com.google.api.client.json.jackson2.JacksonFactory;
30 import com.google.api.services.storage.Storage;
31 import com.google.api.services.storage.model.StorageObject;
32 import com.google.common.base.Strings;
33 import com.google.common.collect.ImmutableMap;
34 
35 import java.io.BufferedOutputStream;
36 import java.io.File;
37 import java.io.FileInputStream;
38 import java.io.FileOutputStream;
39 import java.io.IOException;
40 import java.io.InputStream;
41 import java.util.Collections;
42 import java.util.List;
43 import java.util.Map;
44 import java.util.zip.ZipOutputStream;
45 
46 /**
47  * Uploads a bugreport files to GCS using a simple (no-multipart / no-resume) upload policy.
48  *
49  * <p>It merges bugreport zip file and audio file into one final zip file and uploads it.
50  *
51  * <p>Please see {@code res/values/configs.xml} and {@code res/raw/gcs_credentials.json} for the
52  * configuration.
53  */
54 class SimpleUploaderAsyncTask extends AsyncTask<Void, Void, Boolean> {
55     private static final String TAG = SimpleUploaderAsyncTask.class.getSimpleName();
56 
57     private static final String ACCESS_SCOPE =
58             "https://www.googleapis.com/auth/devstorage.read_write";
59 
60     private static final String STORAGE_METADATA_TITLE = "title";
61 
62     private final Context mContext;
63     private final Result mResult;
64 
65     /**
66      * The uploader uploads only one bugreport each time it is called. This interface is
67      * used to reschedule upload job, if there are more bugreports waiting.
68      *
69      * Pass true to reschedule upload job, false not to reschedule.
70      */
71     interface Result {
reschedule(boolean s)72         void reschedule(boolean s);
73     }
74 
75     /** Constructs SimpleUploaderAsyncTask. */
SimpleUploaderAsyncTask(@onNull Context context, @NonNull Result result)76     SimpleUploaderAsyncTask(@NonNull Context context, @NonNull Result result) {
77         mContext = context;
78         mResult = result;
79     }
80 
uploadSimple( Storage storage, MetaBugReport bugReport, String fileName, InputStream data)81     private StorageObject uploadSimple(
82             Storage storage, MetaBugReport bugReport, String fileName, InputStream data)
83             throws IOException {
84         InputStreamContent mediaContent = new InputStreamContent("application/zip", data);
85 
86         String bucket = mContext.getString(R.string.config_gcs_bucket);
87         if (TextUtils.isEmpty(bucket)) {
88             throw new RuntimeException("config_gcs_bucket is empty.");
89         }
90 
91         // Create GCS MetaData.
92         Map<String, String> metadata = ImmutableMap.of(
93                 STORAGE_METADATA_TITLE, bugReport.getTitle()
94         );
95         StorageObject object = new StorageObject()
96                 .setBucket(bucket)
97                 .setName(fileName)
98                 .setMetadata(metadata)
99                 .setContentDisposition("attachment");
100         Storage.Objects.Insert insertObject = storage.objects().insert(bucket, object,
101                 mediaContent);
102 
103         // The media uploader gzips content by default, and alters the Content-Encoding accordingly.
104         // GCS dutifully stores content as-uploaded. This line disables the media uploader behavior,
105         // so the service stores exactly what is in the InputStream, without transformation.
106         insertObject.getMediaHttpUploader().setDisableGZipContent(true);
107         Log.v(TAG, "started uploading object " + fileName + " to bucket " + bucket);
108         return insertObject.execute();
109     }
110 
upload(MetaBugReport bugReport)111     private void upload(MetaBugReport bugReport) throws IOException {
112         GoogleCredential credential = GoogleCredential
113                 .fromStream(mContext.getResources().openRawResource(R.raw.gcs_credentials))
114                 .createScoped(Collections.singleton(ACCESS_SCOPE));
115         Log.v(TAG, "Created credential");
116         HttpTransport httpTransport = AndroidHttp.newCompatibleTransport();
117         JsonFactory jsonFactory = JacksonFactory.getDefaultInstance();
118 
119         Storage storage = new Storage.Builder(httpTransport, jsonFactory, credential)
120                 .setApplicationName("Bugreportupload/1.0").build();
121 
122         File tmpBugReportFile = zipBugReportFiles(bugReport);
123         Log.d(TAG, "Uploading file " + tmpBugReportFile);
124         try {
125             // Upload filename is bugreport filename, although, now it contains the audio message.
126             String fileName = bugReport.getBugReportFileName();
127             if (Strings.isNullOrEmpty(fileName)) {
128                 // Old bugreports don't contain getBugReportFileName, fallback to getFilePath.
129                 fileName = new File(bugReport.getFilePath()).getName();
130             }
131             try (FileInputStream inputStream = new FileInputStream(tmpBugReportFile)) {
132                 StorageObject object = uploadSimple(storage, bugReport, fileName, inputStream);
133                 Log.v(TAG, "finished uploading object " + object.getName() + " file " + fileName);
134             }
135             File pendingDir = FileUtils.getPendingDir(mContext);
136             // Delete only after successful upload; the files are needed for retry.
137             if (!Strings.isNullOrEmpty(bugReport.getAudioFileName())) {
138                 Log.v(TAG, "Deleting file " + bugReport.getAudioFileName());
139                 new File(pendingDir, bugReport.getAudioFileName()).delete();
140             }
141             if (!Strings.isNullOrEmpty(bugReport.getBugReportFileName())) {
142                 Log.v(TAG, "Deleting file " + bugReport.getBugReportFileName());
143                 new File(pendingDir, bugReport.getBugReportFileName()).delete();
144             }
145         } finally {
146             // Delete the temp file if it's not a MetaBugReport#getFilePath, because it's needed
147             // for retry.
148             if (Strings.isNullOrEmpty(bugReport.getFilePath())) {
149                 Log.v(TAG, "Deleting file " + tmpBugReportFile);
150                 tmpBugReportFile.delete();
151             }
152         }
153     }
154 
zipBugReportFiles(MetaBugReport bugReport)155     private File zipBugReportFiles(MetaBugReport bugReport) throws IOException {
156         if (!Strings.isNullOrEmpty(bugReport.getFilePath())) {
157             // Old bugreports still have this field.
158             return new File(bugReport.getFilePath());
159         }
160         File finalZipFile =
161                 File.createTempFile("bugreport", ".zip", mContext.getCacheDir());
162         File pendingDir = FileUtils.getPendingDir(mContext);
163         try (ZipOutputStream zipStream = new ZipOutputStream(
164                 new BufferedOutputStream(new FileOutputStream(finalZipFile)))) {
165             ZipUtils.extractZippedFileToZipStream(
166                     new File(pendingDir, bugReport.getBugReportFileName()), zipStream);
167             ZipUtils.addFileToZipStream(
168                     new File(pendingDir, bugReport.getAudioFileName()), zipStream);
169         }
170         return finalZipFile;
171     }
172 
173     @Override
onPostExecute(Boolean success)174     protected void onPostExecute(Boolean success) {
175         mResult.reschedule(success);
176     }
177 
178     /** Returns true is there are more files to upload. */
179     @Override
doInBackground(Void... voids)180     protected Boolean doInBackground(Void... voids) {
181         List<MetaBugReport> bugReports = BugStorageUtils.getUploadPendingBugReports(mContext);
182 
183         for (MetaBugReport bugReport : bugReports) {
184             try {
185                 if (isCancelled()) {
186                     BugStorageUtils.setUploadRetry(mContext, bugReport, "Upload Job Cancelled");
187                     return true;
188                 }
189                 upload(bugReport);
190                 BugStorageUtils.setUploadSuccess(mContext, bugReport);
191             } catch (Exception e) {
192                 Log.e(TAG, String.format("Failed uploading %s - likely a transient error",
193                         bugReport.getTimestamp()), e);
194                 BugStorageUtils.setUploadRetry(mContext, bugReport, e);
195             }
196         }
197         return false;
198     }
199 
200     @Override
onCancelled(Boolean success)201     protected void onCancelled(Boolean success) {
202         mResult.reschedule(true);
203     }
204 }
205