Merge "Implement save-to-USB for bug reports." into qt-dev
am: be4a27abd5

Change-Id: If548dbecb13ff32037e166d3a8526ff2fc675658
diff --git a/tests/BugReportApp/res/layout/bug_info_view.xml b/tests/BugReportApp/res/layout/bug_info_view.xml
index a02df20..3736c00 100644
--- a/tests/BugReportApp/res/layout/bug_info_view.xml
+++ b/tests/BugReportApp/res/layout/bug_info_view.xml
@@ -60,6 +60,14 @@
             android:textColor="@android:color/holo_blue_dark"
             android:textSize="28sp" />
 
+        <Button
+            android:id="@+id/bug_info_upload_button"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="@dimen/bug_report_user_action_button_padding"
+            android:textSize="@dimen/bug_report_button_text_size"
+            android:text="@string/bugreport_upload_button_text" />
+
     </LinearLayout>
 
     <LinearLayout
@@ -103,6 +111,14 @@
             android:layout_height="wrap_content"
             android:textSize="28sp" />
 
+        <Button
+            android:id="@+id/bug_info_move_button"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="@dimen/bug_report_user_action_button_padding"
+            android:textSize="@dimen/bug_report_button_text_size"
+            android:text="@string/bugreport_move_button_text" />
+
     </LinearLayout>
 
 </LinearLayout>
\ No newline at end of file
diff --git a/tests/BugReportApp/res/values/dimens.xml b/tests/BugReportApp/res/values/dimens.xml
index ebddcfe..4a5f270 100644
--- a/tests/BugReportApp/res/values/dimens.xml
+++ b/tests/BugReportApp/res/values/dimens.xml
@@ -28,6 +28,8 @@
     <dimen name="bug_report_primary_button_padding">52dp</dimen>
     <dimen name="bug_report_secondary_button_padding">30dp</dimen>
     <dimen name="bug_report_small_button_padding">16dp</dimen>
+    <dimen name="bug_report_button_text_size">28dp</dimen>
+    <dimen name="bug_report_user_action_button_padding">5dp</dimen>
 
     <!-- ProgressBar dimensions -->
     <dimen name="bug_report_progress_bar_margin_top">32dp</dimen>
diff --git a/tests/BugReportApp/res/values/strings.xml b/tests/BugReportApp/res/values/strings.xml
index 491bbc2..814b2a4 100644
--- a/tests/BugReportApp/res/values/strings.xml
+++ b/tests/BugReportApp/res/values/strings.xml
@@ -28,6 +28,8 @@
     <string name="bugreport_dialog_recording_finished" translatable="false">Recording finished</string>
     <string name="bugreport_dialog_in_progress_title" translatable="false">A bug report is already being collected</string>
     <string name="bugreport_dialog_in_progress_title_finished" translatable="false">A bug report has been collected</string>
+    <string name="bugreport_move_button_text" translatable="false">Move</string>
+    <string name="bugreport_upload_button_text" translatable="false">Upload</string>
 
     <string name="toast_permissions_denied" translatable="false">Please grant permissions</string>
     <string name="toast_bug_report_in_progress" translatable="false">Bug report already being collected</string>
diff --git a/tests/BugReportApp/src/com/google/android/car/bugreport/BugInfoAdapter.java b/tests/BugReportApp/src/com/google/android/car/bugreport/BugInfoAdapter.java
index fb7c3fa..b9ce6f8 100644
--- a/tests/BugReportApp/src/com/google/android/car/bugreport/BugInfoAdapter.java
+++ b/tests/BugReportApp/src/com/google/android/car/bugreport/BugInfoAdapter.java
@@ -18,22 +18,29 @@
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
+import android.widget.Button;
 import android.widget.TextView;
 
 import androidx.recyclerview.widget.RecyclerView;
 
 import java.util.List;
 
-/**
- * Adapter class for bug report information
- */
 public class BugInfoAdapter extends RecyclerView.Adapter<BugInfoAdapter.BugInfoViewHolder> {
 
+    static final int BUTTON_TYPE_UPLOAD = 0;
+    static final int BUTTON_TYPE_MOVE = 1;
+
+    /** Provides a handler for click events*/
+    interface ItemClickedListener {
+        /** onItemClicked handles click events differently depending on provided buttonType and
+         * uses additional information provided in metaBugReport. */
+        void onItemClicked(int buttonType, MetaBugReport metaBugReport);
+    }
+
     /**
      * Reference to each bug report info views.
      */
     public static class BugInfoViewHolder extends RecyclerView.ViewHolder {
-
         /** Title view */
         public TextView titleView;
 
@@ -49,6 +56,12 @@
         /** Message View */
         public TextView messageView;
 
+        /** Move Button */
+        public Button moveButton;
+
+        /** Upload Button */
+        public Button uploadButton;
+
         BugInfoViewHolder(View v) {
             super(v);
             titleView = itemView.findViewById(R.id.bug_info_row_title);
@@ -56,13 +69,18 @@
             timestampView = itemView.findViewById(R.id.bug_info_row_timestamp);
             statusView = itemView.findViewById(R.id.bug_info_row_status);
             messageView = itemView.findViewById(R.id.bug_info_row_message);
+            moveButton = itemView.findViewById(R.id.bug_info_move_button);
+            uploadButton = itemView.findViewById(R.id.bug_info_upload_button);
         }
     }
 
-    private List<MetaBugReport> mDataset;
+    private final List<MetaBugReport> mDataset;
+    private final ItemClickedListener mItemClickedListener;
 
-    BugInfoAdapter(List<MetaBugReport> dataSet) {
+
+    BugInfoAdapter(List<MetaBugReport> dataSet, ItemClickedListener itemClickedListener) {
         mDataset = dataSet;
+        mItemClickedListener = itemClickedListener;
     }
 
     @Override
@@ -75,15 +93,31 @@
 
     @Override
     public void onBindViewHolder(BugInfoViewHolder holder, int position) {
+        MetaBugReport bugreport = mDataset.get(position);
         holder.titleView.setText(mDataset.get(position).getTitle());
         holder.userView.setText(mDataset.get(position).getUsername());
         holder.timestampView.setText(mDataset.get(position).getTimestamp());
         holder.statusView.setText(Status.toString(mDataset.get(position).getStatus()));
         holder.messageView.setText(mDataset.get(position).getStatusMessage());
+        if (bugreport.getStatus() == Status.STATUS_PENDING_USER_ACTION.getValue()
+                || bugreport.getStatus() == Status.STATUS_MOVE_FAILED.getValue()
+                || bugreport.getStatus() == Status.STATUS_UPLOAD_FAILED.getValue()) {
+            holder.moveButton.setOnClickListener(
+                    view -> mItemClickedListener.onItemClicked(BUTTON_TYPE_MOVE, bugreport));
+            holder.uploadButton.setOnClickListener(
+                    view -> mItemClickedListener.onItemClicked(BUTTON_TYPE_UPLOAD, bugreport));
+        } else {
+            holder.moveButton.setEnabled(false);
+            holder.uploadButton.setEnabled(false);
+        }
     }
 
     @Override
     public int getItemCount() {
         return mDataset.size();
     }
+
+    public MetaBugReport getDataItemAt(int position) {
+        return mDataset.get(position);
+    }
 }
diff --git a/tests/BugReportApp/src/com/google/android/car/bugreport/BugReportInfoActivity.java b/tests/BugReportApp/src/com/google/android/car/bugreport/BugReportInfoActivity.java
index d9ca7ea..0469bb1 100644
--- a/tests/BugReportApp/src/com/google/android/car/bugreport/BugReportInfoActivity.java
+++ b/tests/BugReportApp/src/com/google/android/car/bugreport/BugReportInfoActivity.java
@@ -19,9 +19,12 @@
 
 import android.app.Activity;
 import android.app.NotificationManager;
+import android.content.ContentResolver;
 import android.content.Intent;
+import android.net.Uri;
 import android.os.AsyncTask;
 import android.os.Bundle;
+import android.provider.DocumentsContract;
 import android.util.Log;
 import android.view.View;
 import android.widget.TextView;
@@ -30,6 +33,10 @@
 import androidx.recyclerview.widget.LinearLayoutManager;
 import androidx.recyclerview.widget.RecyclerView;
 
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
 import java.util.List;
@@ -40,12 +47,74 @@
 public class BugReportInfoActivity extends Activity {
     public static final String TAG = BugReportInfoActivity.class.getSimpleName();
 
+    private static final int SELECT_DIRECTORY_REQUEST_CODE = 1;
+
     private RecyclerView mRecyclerView;
     private RecyclerView.Adapter mAdapter;
     private RecyclerView.LayoutManager mLayoutManager;
     private NotificationManager mNotificationManager;
+    private MetaBugReport mLastSelectedBugReport;
 
-    private static class BugReportInfoTask extends AsyncTask<Void, Void, List<MetaBugReport>> {
+    private static final class AsyncMoveFilesTask extends AsyncTask<Void, Void, Boolean> {
+        private final BugReportInfoActivity mActivity;
+        private final MetaBugReport mBugReport;
+        private final Uri mDestinationDirUri;
+
+        AsyncMoveFilesTask(BugReportInfoActivity activity, MetaBugReport bugReport,
+                Uri destinationDir) {
+            mActivity = activity;
+            mBugReport = bugReport;
+            mDestinationDirUri = destinationDir;
+        }
+
+        @Override
+        protected Boolean doInBackground(Void... params) {
+            Uri sourceUri = BugStorageProvider.buildUriWithBugId(mBugReport.getId());
+            ContentResolver resolver = mActivity.getContentResolver();
+            String documentId = DocumentsContract.getTreeDocumentId(mDestinationDirUri);
+            Uri parentDocumentUri =
+                    DocumentsContract.buildDocumentUriUsingTree(mDestinationDirUri, documentId);
+            String mimeType = resolver.getType(sourceUri);
+            try {
+                Uri newFileUri = DocumentsContract.createDocument(resolver, parentDocumentUri,
+                        mimeType,
+                        new File(mBugReport.getFilePath()).toPath().getFileName().toString());
+                if (newFileUri == null) {
+                    Log.e(TAG, "Unable to create a new file.");
+                    return false;
+                }
+                try (InputStream input = resolver.openInputStream(sourceUri);
+                     OutputStream output = resolver.openOutputStream(newFileUri)) {
+                    byte[] buffer = new byte[4096];
+                    int len;
+                    while ((len = input.read(buffer)) > 0) {
+                        output.write(buffer, 0, len);
+                    }
+                }
+                BugStorageUtils.setBugReportStatus(
+                        mActivity, mBugReport,
+                        com.google.android.car.bugreport.Status.STATUS_MOVE_SUCCESSFUL, "");
+            } catch (IOException e) {
+                Log.e(TAG, "Failed to create the bug report in the location.", e);
+                return false;
+            }
+            return true;
+        }
+
+        @Override
+        protected void onPostExecute(Boolean moveSuccessful) {
+            if (!moveSuccessful) {
+                BugStorageUtils.setBugReportStatus(
+                        mActivity, mBugReport,
+                        com.google.android.car.bugreport.Status.STATUS_MOVE_FAILED, "");
+            }
+            // Refresh the UI to reflect the new status.
+            new BugReportInfoTask(mActivity).execute();
+        }
+    }
+
+    private static final class BugReportInfoTask extends
+            AsyncTask<Void, Void, List<MetaBugReport>> {
         private final WeakReference<BugReportInfoActivity> mBugReportInfoActivityWeakReference;
 
         BugReportInfoTask(BugReportInfoActivity activity) {
@@ -69,7 +138,7 @@
                 Log.w(TAG, "Activity is gone, cancelling onPostExecute.");
                 return;
             }
-            activity.mAdapter = new BugInfoAdapter(result);
+            activity.mAdapter = new BugInfoAdapter(result, activity::onBugReportItemClicked);
             activity.mRecyclerView.setAdapter(activity.mAdapter);
             activity.mRecyclerView.getAdapter().notifyDataSetChanged();
         }
@@ -91,7 +160,7 @@
                 DividerItemDecoration.VERTICAL));
 
         // specify an adapter (see also next example)
-        mAdapter = new BugInfoAdapter(new ArrayList<>());
+        mAdapter = new BugInfoAdapter(new ArrayList<>(), this::onBugReportItemClicked);
         mRecyclerView.setAdapter(mAdapter);
 
         findViewById(R.id.quit_button).setOnClickListener(this::onQuitButtonClick);
@@ -117,6 +186,39 @@
         mNotificationManager.cancel(BugReportService.BUGREPORT_FINISHED_NOTIF_ID);
     }
 
+    private void onBugReportItemClicked(int buttonType, MetaBugReport bugReport) {
+        if (buttonType == BugInfoAdapter.BUTTON_TYPE_UPLOAD) {
+            Log.i(TAG, "Uploading " + bugReport.getFilePath());
+            BugStorageUtils.setBugReportStatus(this, bugReport, Status.STATUS_UPLOAD_PENDING, "");
+            // Refresh the UI to reflect the new status.
+            new BugReportInfoTask(this).execute();
+        } else if (buttonType == BugInfoAdapter.BUTTON_TYPE_MOVE) {
+            Log.i(TAG, "Moving " + bugReport.getFilePath());
+            mLastSelectedBugReport = bugReport;
+            startActivityForResult(new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE),
+                    SELECT_DIRECTORY_REQUEST_CODE);
+        } else {
+            throw new IllegalStateException("unreachable");
+        }
+    }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+        super.onActivityResult(requestCode, resultCode, data);
+        if (requestCode == SELECT_DIRECTORY_REQUEST_CODE && resultCode == RESULT_OK) {
+            int takeFlags =
+                    data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION
+                            | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+            Uri destDirUri = data.getData();
+            getContentResolver().takePersistableUriPermission(destDirUri, takeFlags);
+            if (mLastSelectedBugReport == null) {
+                Log.w(TAG, "No bug report is selected.");
+                return;
+            }
+            new AsyncMoveFilesTask(this, mLastSelectedBugReport, destDirUri).execute();
+        }
+    }
+
     private void onQuitButtonClick(View view) {
         finish();
     }
diff --git a/tests/BugReportApp/src/com/google/android/car/bugreport/BugStorageProvider.java b/tests/BugReportApp/src/com/google/android/car/bugreport/BugStorageProvider.java
index 8e10f8c..3d07acf 100644
--- a/tests/BugReportApp/src/com/google/android/car/bugreport/BugStorageProvider.java
+++ b/tests/BugReportApp/src/com/google/android/car/bugreport/BugStorageProvider.java
@@ -173,12 +173,6 @@
 
     @Nullable
     @Override
-    public String getType(@NonNull Uri uri) {
-        return null;
-    }
-
-    @Nullable
-    @Override
     public Uri insert(@NonNull Uri uri, @Nullable ContentValues values) {
         String table;
         if (values == null) {
@@ -206,6 +200,16 @@
         return null;
     }
 
+    @Nullable
+    @Override
+    public String getType(@NonNull Uri uri) {
+        if (mUriMatcher.match(uri) != URL_MATCHED_BUG_REPORT_ID_URI) {
+            throw new IllegalArgumentException("unknown uri:" + uri);
+        }
+        // We only store zip files in this provider.
+        return "application/zip";
+    }
+
     @Override
     public int delete(
             @NonNull Uri uri, @Nullable String selection, @Nullable String[] selectionArgs) {
@@ -291,11 +295,20 @@
         int modeBits = ParcelFileDescriptor.parseMode(mode);
         try {
             return ParcelFileDescriptor.open(new File(path), modeBits, mHandler, e -> {
+                if (mode.equals("r")) {
+                    Log.i(TAG, "File " + path + " opened in read-only mode.");
+                    return;
+                } else if (!mode.equals("w")) {
+                    Log.e(TAG, "Only read-only or write-only mode supported; mode=" + mode);
+                    return;
+                }
+                Log.i(TAG, "File " + path + " opened in write-only mode.");
                 Status status;
                 if (e == null) {
                     // success writing the file. Update the field to indicate bugreport
                     // is ready for upload
-                    status = Status.STATUS_UPLOAD_PENDING;
+                    status = JobSchedulingUtils.uploadByDefault() ? Status.STATUS_UPLOAD_PENDING
+                            : Status.STATUS_PENDING_USER_ACTION;
                     JobSchedulingUtils.scheduleUploadJob(BugStorageProvider.this.getContext());
                 } else {
                     // We log it and ignore it
diff --git a/tests/BugReportApp/src/com/google/android/car/bugreport/JobSchedulingUtils.java b/tests/BugReportApp/src/com/google/android/car/bugreport/JobSchedulingUtils.java
index 7e8c933..7b9cefb 100644
--- a/tests/BugReportApp/src/com/google/android/car/bugreport/JobSchedulingUtils.java
+++ b/tests/BugReportApp/src/com/google/android/car/bugreport/JobSchedulingUtils.java
@@ -58,4 +58,16 @@
                     .setBackoffCriteria(RETRY_DELAY_IN_MS, JobInfo.BACKOFF_POLICY_LINEAR)
                     .build());
     }
+
+    /** uploadByDefault switches app behavior between two workflows.
+     *
+     * Returns true if collected bugreports are uploaded automatically.
+     *
+     * Otherwise, it maps to an alternative workflow that requires user action after bugreport
+     * is successfully written. A user then has an option to choose whether to upload the bugreport
+     * or copy it to an external drive.
+     */
+    static boolean uploadByDefault() {
+        return true;
+    }
 }
diff --git a/tests/BugReportApp/src/com/google/android/car/bugreport/Status.java b/tests/BugReportApp/src/com/google/android/car/bugreport/Status.java
index f18baae..9142b91 100644
--- a/tests/BugReportApp/src/com/google/android/car/bugreport/Status.java
+++ b/tests/BugReportApp/src/com/google/android/car/bugreport/Status.java
@@ -33,7 +33,16 @@
     STATUS_UPLOAD_FAILED(4),
 
     // Bugreport is cancelled by user
-    STATUS_USER_CANCELLED(5);
+    STATUS_USER_CANCELLED(5),
+
+    // Bugreport is pending user choice on whether to upload or copy.
+    STATUS_PENDING_USER_ACTION(6),
+
+    // Bugreport was moved successfully.
+    STATUS_MOVE_SUCCESSFUL(7),
+
+    // Bugreport move has failed.
+    STATUS_MOVE_FAILED(8);
 
     private final int mValue;
 
@@ -50,17 +59,23 @@
     public static String toString(int value) {
         switch (value) {
             case 0:
-                return "Write Pending";
+                return "Write pending";
             case 1:
                 return "Write failed";
             case 2:
                 return "Upload pending";
             case 3:
-                return "Upload success";
+                return "Upload successful";
             case 4:
                 return "Upload failed";
             case 5:
                 return "User cancelled";
+            case 6:
+                return "Pending user action";
+            case 7:
+                return "Move successful";
+            case 8:
+                return "Move failed";
         }
         return "unknown";
     }