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.PackageUtils.getPackageVersion;
19 
20 import android.app.Activity;
21 import android.app.NotificationManager;
22 import android.content.ContentResolver;
23 import android.content.Intent;
24 import android.content.res.AssetFileDescriptor;
25 import android.database.ContentObserver;
26 import android.net.Uri;
27 import android.os.AsyncTask;
28 import android.os.Bundle;
29 import android.os.Handler;
30 import android.os.UserHandle;
31 import android.provider.DocumentsContract;
32 import android.util.Log;
33 import android.view.View;
34 import android.widget.TextView;
35 
36 import androidx.recyclerview.widget.DividerItemDecoration;
37 import androidx.recyclerview.widget.LinearLayoutManager;
38 import androidx.recyclerview.widget.RecyclerView;
39 
40 import com.google.common.base.Preconditions;
41 import com.google.common.base.Strings;
42 import com.google.common.io.ByteStreams;
43 
44 import java.io.BufferedOutputStream;
45 import java.io.File;
46 import java.io.FileDescriptor;
47 import java.io.IOException;
48 import java.io.InputStream;
49 import java.io.OutputStream;
50 import java.io.PrintWriter;
51 import java.lang.ref.WeakReference;
52 import java.util.ArrayList;
53 import java.util.List;
54 import java.util.zip.ZipEntry;
55 import java.util.zip.ZipInputStream;
56 import java.util.zip.ZipOutputStream;
57 
58 /**
59  * Provides an activity that provides information on the bugreports that are filed.
60  */
61 public class BugReportInfoActivity extends Activity {
62     public static final String TAG = BugReportInfoActivity.class.getSimpleName();
63 
64     /** Used for moving bug reports to a new location (e.g. USB drive). */
65     private static final int SELECT_DIRECTORY_REQUEST_CODE = 1;
66 
67     /** Used to start {@link BugReportActivity} to add audio message. */
68     private static final int ADD_AUDIO_MESSAGE_REQUEST_CODE = 2;
69 
70     private RecyclerView mRecyclerView;
71     private BugInfoAdapter mBugInfoAdapter;
72     private RecyclerView.LayoutManager mLayoutManager;
73     private NotificationManager mNotificationManager;
74     private MetaBugReport mLastSelectedBugReport;
75     private BugInfoAdapter.BugInfoViewHolder mLastSelectedBugInfoViewHolder;
76     private BugStorageObserver mBugStorageObserver;
77     private Config mConfig;
78     private boolean mAudioRecordingStarted;
79 
80     @Override
onCreate(Bundle savedInstanceState)81     protected void onCreate(Bundle savedInstanceState) {
82         Preconditions.checkState(Config.isBugReportEnabled(), "BugReport is disabled.");
83 
84         super.onCreate(savedInstanceState);
85         setContentView(R.layout.bug_report_info_activity);
86 
87         mNotificationManager = getSystemService(NotificationManager.class);
88 
89         mRecyclerView = findViewById(R.id.rv_bug_report_info);
90         mRecyclerView.setHasFixedSize(true);
91         // use a linear layout manager
92         mLayoutManager = new LinearLayoutManager(this);
93         mRecyclerView.setLayoutManager(mLayoutManager);
94         mRecyclerView.addItemDecoration(new DividerItemDecoration(mRecyclerView.getContext(),
95                 DividerItemDecoration.VERTICAL));
96 
97         mConfig = new Config();
98         mConfig.start();
99 
100         mBugInfoAdapter = new BugInfoAdapter(this::onBugReportItemClicked, mConfig);
101         mRecyclerView.setAdapter(mBugInfoAdapter);
102 
103         mBugStorageObserver = new BugStorageObserver(this, new Handler());
104 
105         findViewById(R.id.quit_button).setOnClickListener(this::onQuitButtonClick);
106         findViewById(R.id.start_bug_report_button).setOnClickListener(
107                 this::onStartBugReportButtonClick);
108         ((TextView) findViewById(R.id.version_text_view)).setText(
109                 String.format("v%s", getPackageVersion(this)));
110 
111         cancelBugReportFinishedNotification();
112     }
113 
114     @Override
onStart()115     protected void onStart() {
116         super.onStart();
117         new BugReportsLoaderAsyncTask(this).execute();
118         // As BugStorageProvider is running under user0, we register using USER_ALL.
119         getContentResolver().registerContentObserver(BugStorageProvider.BUGREPORT_CONTENT_URI, true,
120                 mBugStorageObserver, UserHandle.USER_ALL);
121     }
122 
123     @Override
onStop()124     protected void onStop() {
125         super.onStop();
126         getContentResolver().unregisterContentObserver(mBugStorageObserver);
127     }
128 
129     /**
130      * Dismisses {@link BugReportService#BUGREPORT_FINISHED_NOTIF_ID}, otherwise the notification
131      * will stay there forever if this activity opened through the App Launcher.
132      */
cancelBugReportFinishedNotification()133     private void cancelBugReportFinishedNotification() {
134         mNotificationManager.cancel(BugReportService.BUGREPORT_FINISHED_NOTIF_ID);
135     }
136 
onBugReportItemClicked( int buttonType, MetaBugReport bugReport, BugInfoAdapter.BugInfoViewHolder holder)137     private void onBugReportItemClicked(
138             int buttonType, MetaBugReport bugReport, BugInfoAdapter.BugInfoViewHolder holder) {
139         if (buttonType == BugInfoAdapter.BUTTON_TYPE_UPLOAD) {
140             Log.i(TAG, "Uploading " + bugReport.getTimestamp());
141             BugStorageUtils.setBugReportStatus(this, bugReport, Status.STATUS_UPLOAD_PENDING, "");
142             // Refresh the UI to reflect the new status.
143             new BugReportsLoaderAsyncTask(this).execute();
144         } else if (buttonType == BugInfoAdapter.BUTTON_TYPE_MOVE) {
145             Log.i(TAG, "Moving " + bugReport.getTimestamp());
146             mLastSelectedBugReport = bugReport;
147             mLastSelectedBugInfoViewHolder = holder;
148             startActivityForResult(new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE),
149                     SELECT_DIRECTORY_REQUEST_CODE);
150         } else if (buttonType == BugInfoAdapter.BUTTON_TYPE_ADD_AUDIO) {
151             // Check mAudioRecordingStarted to prevent double click to BUTTON_TYPE_ADD_AUDIO.
152             if (!mAudioRecordingStarted) {
153                 mAudioRecordingStarted = true;
154                 startActivityForResult(BugReportActivity.buildAddAudioIntent(this, bugReport),
155                         ADD_AUDIO_MESSAGE_REQUEST_CODE);
156             }
157         } else {
158             throw new IllegalStateException("unreachable");
159         }
160     }
161 
162     @Override
onActivityResult(int requestCode, int resultCode, Intent data)163     protected void onActivityResult(int requestCode, int resultCode, Intent data) {
164         super.onActivityResult(requestCode, resultCode, data);
165         if (requestCode == SELECT_DIRECTORY_REQUEST_CODE && resultCode == RESULT_OK) {
166             int takeFlags =
167                     data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION
168                             | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
169             Uri destDirUri = data.getData();
170             getContentResolver().takePersistableUriPermission(destDirUri, takeFlags);
171             if (mLastSelectedBugReport == null || mLastSelectedBugInfoViewHolder == null) {
172                 Log.w(TAG, "No bug report is selected.");
173                 return;
174             }
175             MetaBugReport updatedBugReport = BugStorageUtils.setBugReportStatus(this,
176                     mLastSelectedBugReport, Status.STATUS_MOVE_IN_PROGRESS, "");
177             mBugInfoAdapter.updateBugReportInDataSet(
178                     updatedBugReport, mLastSelectedBugInfoViewHolder.getAdapterPosition());
179             new AsyncMoveFilesTask(
180                 this,
181                     mBugInfoAdapter,
182                     updatedBugReport,
183                     mLastSelectedBugInfoViewHolder,
184                     destDirUri).execute();
185         }
186     }
187 
onQuitButtonClick(View view)188     private void onQuitButtonClick(View view) {
189         finish();
190     }
191 
onStartBugReportButtonClick(View view)192     private void onStartBugReportButtonClick(View view) {
193         Intent intent = new Intent(this, BugReportActivity.class);
194         // Clear top is needed, otherwise multiple BugReportActivity-ies get opened and
195         // MediaRecorder crashes.
196         intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
197         startActivity(intent);
198     }
199 
200     /**
201      * Print the Provider's state into the given stream. This gets invoked if
202      * you run "adb shell dumpsys activity BugReportInfoActivity".
203      *
204      * @param prefix Desired prefix to prepend at each line of output.
205      * @param fd The raw file descriptor that the dump is being sent to.
206      * @param writer The PrintWriter to which you should dump your state.  This will be
207      * closed for you after you return.
208      * @param args additional arguments to the dump request.
209      */
dump(String prefix, FileDescriptor fd, PrintWriter writer, String[] args)210     public void dump(String prefix, FileDescriptor fd, PrintWriter writer, String[] args) {
211         super.dump(prefix, fd, writer, args);
212         mConfig.dump(prefix, writer);
213     }
214 
215     /**
216      * Moves bugreport zip to USB drive and updates RecyclerView.
217      *
218      * <p>It merges bugreport zip file and audio file into one final zip file and moves it.
219      */
220     private static final class AsyncMoveFilesTask extends AsyncTask<Void, Void, MetaBugReport> {
221         private final BugReportInfoActivity mActivity;
222         private final MetaBugReport mBugReport;
223         private final Uri mDestinationDirUri;
224         /** RecyclerView.Adapter that contains all the bug reports. */
225         private final BugInfoAdapter mBugInfoAdapter;
226         /** ViewHolder for {@link #mBugReport}. */
227         private final BugInfoAdapter.BugInfoViewHolder mBugViewHolder;
228         private final ContentResolver mResolver;
229 
AsyncMoveFilesTask(BugReportInfoActivity activity, BugInfoAdapter bugInfoAdapter, MetaBugReport bugReport, BugInfoAdapter.BugInfoViewHolder holder, Uri destinationDir)230         AsyncMoveFilesTask(BugReportInfoActivity activity, BugInfoAdapter bugInfoAdapter,
231                 MetaBugReport bugReport, BugInfoAdapter.BugInfoViewHolder holder,
232                 Uri destinationDir) {
233             mActivity = activity;
234             mBugInfoAdapter = bugInfoAdapter;
235             mBugReport = bugReport;
236             mBugViewHolder = holder;
237             mDestinationDirUri = destinationDir;
238             mResolver = mActivity.getContentResolver();
239         }
240 
241         /** Moves the bugreport to the USB drive and returns the updated {@link MetaBugReport}. */
242         @Override
doInBackground(Void... params)243         protected MetaBugReport doInBackground(Void... params) {
244             try {
245                 return copyFilesToUsb();
246             } catch (IOException e) {
247                 Log.e(TAG, "Failed to copy bugreport "
248                         + mBugReport.getTimestamp() + " to USB", e);
249                 return BugStorageUtils.setBugReportStatus(
250                     mActivity, mBugReport,
251                     com.google.android.car.bugreport.Status.STATUS_MOVE_FAILED, e);
252             }
253         }
254 
copyFilesToUsb()255         private MetaBugReport copyFilesToUsb() throws IOException {
256             String documentId = DocumentsContract.getTreeDocumentId(mDestinationDirUri);
257             Uri parentDocumentUri =
258                     DocumentsContract.buildDocumentUriUsingTree(mDestinationDirUri, documentId);
259             if (!Strings.isNullOrEmpty(mBugReport.getFilePath())) {
260                 // There are still old bugreports with deprecated filePath.
261                 Uri sourceUri = BugStorageProvider.buildUriWithSegment(
262                         mBugReport.getId(), BugStorageProvider.URL_SEGMENT_OPEN_FILE);
263                 copyFileToUsb(
264                         new File(mBugReport.getFilePath()).getName(), sourceUri, parentDocumentUri);
265             } else {
266                 mergeFilesAndCopyToUsb(parentDocumentUri);
267             }
268             Log.d(TAG, "Deleting local bug report files.");
269             BugStorageUtils.deleteBugReportFiles(mActivity, mBugReport.getId());
270             return BugStorageUtils.setBugReportStatus(mActivity, mBugReport,
271                     com.google.android.car.bugreport.Status.STATUS_MOVE_SUCCESSFUL,
272                     "Moved to: " + mDestinationDirUri.getPath());
273         }
274 
mergeFilesAndCopyToUsb(Uri parentDocumentUri)275         private void mergeFilesAndCopyToUsb(Uri parentDocumentUri) throws IOException {
276             Uri sourceBugReport = BugStorageProvider.buildUriWithSegment(
277                     mBugReport.getId(), BugStorageProvider.URL_SEGMENT_OPEN_BUGREPORT_FILE);
278             Uri sourceAudio = BugStorageProvider.buildUriWithSegment(
279                     mBugReport.getId(), BugStorageProvider.URL_SEGMENT_OPEN_AUDIO_FILE);
280             String mimeType = mResolver.getType(sourceBugReport); // It's a zip file.
281             Uri newFileUri = DocumentsContract.createDocument(
282                     mResolver, parentDocumentUri, mimeType, mBugReport.getBugReportFileName());
283             if (newFileUri == null) {
284                 throw new IOException(
285                         "Unable to create a file " + mBugReport.getBugReportFileName() + " in USB");
286             }
287             try (InputStream bugReportInput = mResolver.openInputStream(sourceBugReport);
288                  AssetFileDescriptor fd = mResolver.openAssetFileDescriptor(newFileUri, "w");
289                  OutputStream outputStream = fd.createOutputStream();
290                  ZipOutputStream zipOutStream =
291                          new ZipOutputStream(new BufferedOutputStream(outputStream))) {
292                 // Extract bugreport zip file to the final zip file in USB drive.
293                 ZipInputStream zipInStream = new ZipInputStream(bugReportInput);
294                 ZipEntry entry;
295                 while ((entry = zipInStream.getNextEntry()) != null) {
296                     ZipUtils.writeInputStreamToZipStream(
297                             entry.getName(), zipInStream, zipOutStream);
298                 }
299                 // Add audio file to the final zip file.
300                 if (!Strings.isNullOrEmpty(mBugReport.getAudioFileName())) {
301                     try (InputStream audioInput = mResolver.openInputStream(sourceAudio)) {
302                         ZipUtils.writeInputStreamToZipStream(
303                                 mBugReport.getAudioFileName(), audioInput, zipOutStream);
304                     }
305                 }
306             }
307             try (AssetFileDescriptor fd = mResolver.openAssetFileDescriptor(newFileUri, "w")) {
308                 // Force sync the written data from memory to the disk.
309                 fd.getFileDescriptor().sync();
310             }
311             Log.d(TAG, "Writing to " + newFileUri + " finished");
312         }
313 
copyFileToUsb(String filename, Uri sourceUri, Uri parentDocumentUri)314         private void copyFileToUsb(String filename, Uri sourceUri, Uri parentDocumentUri)
315                 throws IOException {
316             String mimeType = mResolver.getType(sourceUri);
317             Uri newFileUri = DocumentsContract.createDocument(
318                     mResolver, parentDocumentUri, mimeType, filename);
319             if (newFileUri == null) {
320                 throw new IOException("Unable to create a file " + filename + " in USB");
321             }
322             try (InputStream input = mResolver.openInputStream(sourceUri);
323                  AssetFileDescriptor fd = mResolver.openAssetFileDescriptor(newFileUri, "w")) {
324                 OutputStream output = fd.createOutputStream();
325                 ByteStreams.copy(input, output);
326                 // Force sync the written data from memory to the disk.
327                 fd.getFileDescriptor().sync();
328             }
329         }
330 
331         @Override
onPostExecute(MetaBugReport updatedBugReport)332         protected void onPostExecute(MetaBugReport updatedBugReport) {
333             // Refresh the UI to reflect the new status.
334             mBugInfoAdapter.updateBugReportInDataSet(
335                     updatedBugReport, mBugViewHolder.getAdapterPosition());
336         }
337     }
338 
339     /** Asynchronously loads bugreports from {@link BugStorageProvider}. */
340     private static final class BugReportsLoaderAsyncTask extends
341             AsyncTask<Void, Void, List<MetaBugReport>> {
342         private final WeakReference<BugReportInfoActivity> mBugReportInfoActivityWeakReference;
343 
BugReportsLoaderAsyncTask(BugReportInfoActivity activity)344         BugReportsLoaderAsyncTask(BugReportInfoActivity activity) {
345             mBugReportInfoActivityWeakReference = new WeakReference<>(activity);
346         }
347 
348         @Override
doInBackground(Void... voids)349         protected List<MetaBugReport> doInBackground(Void... voids) {
350             BugReportInfoActivity activity = mBugReportInfoActivityWeakReference.get();
351             if (activity == null) {
352                 Log.w(TAG, "Activity is gone, cancelling BugReportsLoaderAsyncTask.");
353                 return new ArrayList<>();
354             }
355             return BugStorageUtils.getAllBugReportsDescending(activity);
356         }
357 
358         @Override
onPostExecute(List<MetaBugReport> result)359         protected void onPostExecute(List<MetaBugReport> result) {
360             BugReportInfoActivity activity = mBugReportInfoActivityWeakReference.get();
361             if (activity == null) {
362                 Log.w(TAG, "Activity is gone, cancelling onPostExecute.");
363                 return;
364             }
365             activity.mBugInfoAdapter.setDataset(result);
366         }
367     }
368 
369     /** Observer for {@link BugStorageProvider}. */
370     private static class BugStorageObserver extends ContentObserver {
371         private final BugReportInfoActivity mInfoActivity;
372 
373         /**
374          * Creates a content observer.
375          *
376          * @param activity A {@link BugReportInfoActivity} instance.
377          * @param handler The handler to run {@link #onChange} on, or null if none.
378          */
BugStorageObserver(BugReportInfoActivity activity, Handler handler)379         BugStorageObserver(BugReportInfoActivity activity, Handler handler) {
380             super(handler);
381             mInfoActivity = activity;
382         }
383 
384         @Override
onChange(boolean selfChange)385         public void onChange(boolean selfChange) {
386             new BugReportsLoaderAsyncTask(mInfoActivity).execute();
387         }
388     }
389 }
390