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