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