• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 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.googleapis.json.GoogleJsonError;
27 import com.google.api.client.googleapis.json.GoogleJsonResponseException;
28 import com.google.api.client.http.HttpTransport;
29 import com.google.api.client.http.InputStreamContent;
30 import com.google.api.client.json.JsonFactory;
31 import com.google.api.client.json.jackson2.JacksonFactory;
32 import com.google.api.services.storage.Storage;
33 import com.google.api.services.storage.model.StorageObject;
34 import com.google.common.base.Strings;
35 import com.google.common.collect.ImmutableMap;
36 
37 import java.io.BufferedOutputStream;
38 import java.io.File;
39 import java.io.FileInputStream;
40 import java.io.FileOutputStream;
41 import java.io.IOException;
42 import java.io.InputStream;
43 import java.nio.file.Files;
44 import java.util.Collections;
45 import java.util.List;
46 import java.util.Map;
47 import java.util.zip.ZipOutputStream;
48 
49 /**
50  * Uploads a bugreport files to GCS using a simple (no-multipart / no-resume) upload policy.
51  *
52  * <p>It merges bugreport zip file and audio file into one final zip file and uploads it.
53  *
54  * <p>Please see {@code res/values/configs.xml} and {@code res/raw/gcs_credentials.json} for the
55  * configuration.
56  *
57  * <p>Must be run under user0.
58  */
59 class SimpleUploaderAsyncTask extends AsyncTask<Void, Void, Boolean> {
60     private static final String TAG = SimpleUploaderAsyncTask.class.getSimpleName();
61 
62     private static final String ACCESS_SCOPE =
63             "https://www.googleapis.com/auth/devstorage.read_write";
64 
65     private static final String STORAGE_METADATA_TITLE = "title";
66 
67     private final Context mContext;
68     private final Result mResult;
69 
70     /**
71      * The uploader uploads only one bugreport each time it is called. This interface is
72      * used to reschedule upload job, if there are more bugreports waiting.
73      *
74      * Pass true to reschedule upload job, false not to reschedule.
75      */
76     interface Result {
reschedule(boolean s)77         void reschedule(boolean s);
78     }
79 
80     /** Constructs SimpleUploaderAsyncTask. */
SimpleUploaderAsyncTask(@onNull Context context, @NonNull Result result)81     SimpleUploaderAsyncTask(@NonNull Context context, @NonNull Result result) {
82         mContext = context;
83         mResult = result;
84     }
85 
uploadSimple( Storage storage, MetaBugReport bugReport, String uploadName, InputStream data)86     private StorageObject uploadSimple(
87             Storage storage, MetaBugReport bugReport, String uploadName, InputStream data)
88             throws IOException {
89         InputStreamContent mediaContent = new InputStreamContent("application/zip", data);
90 
91         String bucket = mContext.getString(R.string.config_gcs_bucket);
92         if (TextUtils.isEmpty(bucket)) {
93             throw new RuntimeException("config_gcs_bucket is empty.");
94         }
95 
96         // Create GCS MetaData.
97         Map<String, String> metadata = ImmutableMap.of(
98                 STORAGE_METADATA_TITLE, bugReport.getTitle()
99         );
100         StorageObject object = new StorageObject()
101                 .setBucket(bucket)
102                 .setName(uploadName)
103                 .setMetadata(metadata)
104                 .setContentDisposition("attachment");
105         Storage.Objects.Insert insertObject = storage.objects().insert(bucket, object,
106                 mediaContent);
107 
108         // The media uploader gzips content by default, and alters the Content-Encoding accordingly.
109         // GCS dutifully stores content as-uploaded. This line disables the media uploader behavior,
110         // so the service stores exactly what is in the InputStream, without transformation.
111         insertObject.getMediaHttpUploader().setDisableGZipContent(true);
112         Log.v(TAG, "started uploading object " + uploadName + " to bucket " + bucket);
113         return insertObject.execute();
114     }
115 
deleteFileQuietly(File file)116     private static void deleteFileQuietly(File file) {
117         try {
118             Files.delete(file.toPath());
119         } catch (IOException | SecurityException e) {
120             Log.w(TAG, "Failed to delete " + file + ". Ignoring the error.", e);
121         }
122     }
123 
upload(MetaBugReport bugReport)124     private void upload(MetaBugReport bugReport) throws IOException {
125         GoogleCredential credential = GoogleCredential
126                 .fromStream(mContext.getResources().openRawResource(R.raw.gcs_credentials))
127                 .createScoped(Collections.singleton(ACCESS_SCOPE));
128         Log.v(TAG, "Created credential");
129         HttpTransport httpTransport = AndroidHttp.newCompatibleTransport();
130         JsonFactory jsonFactory = JacksonFactory.getDefaultInstance();
131 
132         Storage storage = new Storage.Builder(httpTransport, jsonFactory, credential)
133                 .setApplicationName("Bugreportupload/1.0").build();
134 
135         File tmpBugReportFile = zipBugReportFiles(bugReport);
136         String uploadName = bugReport.getBugReportFileName();
137         Log.d(TAG, "Uploading file " + tmpBugReportFile + " as " + uploadName);
138         try (FileInputStream inputStream = new FileInputStream(tmpBugReportFile)) {
139             StorageObject object = uploadSimple(storage, bugReport, uploadName, inputStream);
140             Log.v(TAG, "finished uploading object " + object.getName());
141             File pendingDir = FileUtils.getPendingDir(mContext);
142             // Delete only after successful upload; the files are needed for retry.
143             if (!Strings.isNullOrEmpty(bugReport.getAudioFileName())) {
144                 Log.v(TAG, "Deleting file " + bugReport.getAudioFileName());
145                 deleteFileQuietly(new File(pendingDir, bugReport.getAudioFileName()));
146             }
147             if (!Strings.isNullOrEmpty(bugReport.getBugReportFileName())) {
148                 Log.v(TAG, "Deleting file " + bugReport.getBugReportFileName());
149                 deleteFileQuietly(new File(pendingDir, bugReport.getBugReportFileName()));
150             }
151         } finally {
152             Log.v(TAG, "Deleting file " + tmpBugReportFile);
153             // No need to throw exception even if it fails to delete the file, as the task
154             // shouldn't retry the upload again.
155             deleteFileQuietly(tmpBugReportFile);
156         }
157     }
158 
zipBugReportFiles(MetaBugReport bugReport)159     private File zipBugReportFiles(MetaBugReport bugReport) throws IOException {
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         boolean shouldRescheduleJob = false;
183 
184         for (MetaBugReport bugReport : bugReports) {
185             try {
186                 if (isCancelled()) {
187                     BugStorageUtils.setUploadRetry(mContext, bugReport, "Upload Job Cancelled");
188                     return true;
189                 }
190                 upload(bugReport);
191                 BugStorageUtils.setUploadSuccess(mContext, bugReport);
192             } catch (Exception e) {
193                 if (isFileExistsError(e)) {
194                     Log.w(TAG, "Failed uploading " + bugReport.getTimestamp()
195                             + " - it was already uploaded before. Marking it success.");
196                     // It may leave bugreport files in the device for some time, but they are
197                     // cleaned-up during ExpireOldBugReportsJob.
198                     BugStorageUtils.setUploadedBefore(mContext, bugReport, e);
199                     continue;
200                 }
201                 Log.w(TAG, String.format("Failed uploading %s - likely a transient error",
202                         bugReport.getTimestamp()), e);
203                 BugStorageUtils.setUploadRetry(mContext, bugReport, e);
204                 shouldRescheduleJob = true;
205             }
206         }
207         return shouldRescheduleJob;
208     }
209 
210     /** Return true if the file exists with the same name in the back-end. */
isFileExistsError(Exception e)211     private static boolean isFileExistsError(Exception e) {
212         if (!(e instanceof GoogleJsonResponseException)) {
213             return false;
214         }
215         GoogleJsonError error = ((GoogleJsonResponseException) e).getDetails();
216         // Note: In order to replace existing objects, both storage.objects.create and
217         // storage.objects.delete permissions are required.
218         // https://cloud.google.com/storage/docs/access-control/iam-permissions#object_permissions
219         return error != null
220                 && error.getCode() == 403
221                 && error.getMessage().contains("storage.objects.delete");
222     }
223 
224     @Override
onCancelled(Boolean success)225     protected void onCancelled(Boolean success) {
226         mResult.reschedule(true);
227     }
228 }
229