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