Implement backup in terminal

1. Add an option to backup in recovery
2. If it is set, preserve root_part as root_part_backup
3. If backup exists, terminal app mounts it in /mnt/backup
4. Provide remove-backup button as well
5. Remove every file in linux during uninstallation

Bug: 366386783
Test: backup & check
Change-Id: Ic7e2d38172615358253d92e26a63ec6b368d3b94
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
index 95bcbbc..e2bb28f 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
@@ -18,12 +18,16 @@
 import android.content.Intent
 import android.os.Bundle
 import android.util.Log
+import android.view.View
 import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.isVisible
 import androidx.lifecycle.lifecycleScope
 import com.android.virtualization.vmlauncher.InstallUtils
 import com.google.android.material.card.MaterialCardView
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import com.google.android.material.snackbar.Snackbar
 import java.io.IOException
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
 
@@ -34,30 +38,90 @@
         super.onCreate(savedInstanceState)
         setContentView(R.layout.settings_recovery)
         val resetCard = findViewById<MaterialCardView>(R.id.settings_recovery_reset_card)
-        val dialog = MaterialAlertDialogBuilder(this)
-            .setTitle(R.string.settings_recovery_reset_dialog_title)
-            .setMessage(R.string.settings_recovery_reset_dialog_message)
-            .setPositiveButton(R.string.settings_recovery_reset_dialog_confirm) { _, _ ->
-                // This coroutine will be killed when the activity is killed. The behavior is both acceptable
-                // either removing is done or not
-                lifecycleScope.launch(Dispatchers.IO) {
-                    try {
-                        InstallUtils.unInstall(this@SettingsRecoveryActivity)
-                        // Restart terminal
-                        val intent =
-                            baseContext.packageManager.getLaunchIntentForPackage(baseContext.packageName)
-                        intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
-                        finish()
-                        startActivity(intent)
-                    } catch (e: IOException) {
-                        Log.e(TAG, "VM image reset failed.")
+        resetCard.setOnClickListener {
+            var backupRootfs = false
+            val dialog = MaterialAlertDialogBuilder(this)
+                .setTitle(R.string.settings_recovery_reset_dialog_title)
+                .setMultiChoiceItems(arrayOf(getString(R.string.settings_recovery_reset_dialog_backup_option)), booleanArrayOf(backupRootfs)) {_, _, checked ->
+                    backupRootfs = checked
+                }
+                .setPositiveButton(R.string.settings_recovery_reset_dialog_confirm) { _, _ ->
+                    // This coroutine will be killed when the activity is killed. The behavior is both acceptable
+                    // either removing is done or not
+                    runInBackgroundAndRestartApp {
+                        uninstall(backupRootfs)
                     }
                 }
-            }
-            .setNegativeButton(R.string.settings_recovery_reset_dialog_cancel) { dialog, _ -> dialog.dismiss() }
-            .create()
-        resetCard.setOnClickListener {
+                .setNegativeButton(R.string.settings_recovery_reset_dialog_cancel) { dialog, _ -> dialog.dismiss() }
+                .create()
             dialog.show()
         }
+        val resetBackupCard = findViewById<View>(R.id.settings_recovery_reset_backup_card)
+        resetBackupCard.isVisible = InstallUtils.getBackupFile(this).exists()
+
+        resetBackupCard.setOnClickListener {
+            val dialog = MaterialAlertDialogBuilder(this)
+                .setTitle(R.string.settings_recovery_remove_backup_title)
+                .setMessage(R.string.settings_recovery_remove_backup_sub_title)
+                .setPositiveButton(R.string.settings_recovery_reset_dialog_confirm) { _, _ ->
+                    runInBackgroundAndRestartApp {
+                        removeBackup()
+                    }
+                }
+                .setNegativeButton(R.string.settings_recovery_reset_dialog_cancel) { dialog, _ -> dialog.dismiss() }
+                .create()
+            dialog.show()
+        }
+    }
+
+    private fun removeBackup(): Unit {
+        if (!InstallUtils.getBackupFile(this@SettingsRecoveryActivity).delete()) {
+            Snackbar.make(
+                findViewById(android.R.id.content),
+                R.string.settings_recovery_error_during_removing_backup,
+                Snackbar.LENGTH_SHORT
+            ).show();
+            Log.e(TAG, "cannot remove backup")
+        }
+    }
+
+    private fun uninstall(backupRootfs: Boolean): Unit {
+        var backupDone = false
+        try {
+            if (backupRootfs) {
+                InstallUtils.backupRootFs(this@SettingsRecoveryActivity)
+                backupDone = true
+            }
+            InstallUtils.deleteInstallation(this@SettingsRecoveryActivity)
+        } catch (e: IOException) {
+            val errorMsgId = if (backupRootfs && !backupDone) R.string.settings_recovery_error_due_to_backup
+                    else R.string.settings_recovery_error;
+            Snackbar.make(
+                findViewById(android.R.id.content),
+                errorMsgId,
+                Snackbar.LENGTH_SHORT
+            ).show();
+            Log.e(TAG, "cannot recovery ", e)
+        }
+    }
+
+    private fun runInBackgroundAndRestartApp(backgroundWork: suspend CoroutineScope.() -> Unit): Unit {
+        findViewById<View>(R.id.setting_recovery_card_container).visibility = View.INVISIBLE
+        findViewById<View>(R.id.recovery_boot_progress).visibility = View.VISIBLE
+        lifecycleScope.launch(Dispatchers.IO) {
+            backgroundWork()
+        }.invokeOnCompletion {
+            runOnUiThread {
+                findViewById<View>(R.id.setting_recovery_card_container).visibility =
+                    View.VISIBLE
+                findViewById<View>(R.id.recovery_boot_progress).visibility = View.INVISIBLE
+                // Restart terminal
+                val intent =
+                    baseContext.packageManager.getLaunchIntentForPackage(baseContext.packageName)
+                intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
+                finish()
+                startActivity(intent)
+            }
+        }
     }
 }
\ No newline at end of file
diff --git a/android/TerminalApp/res/layout/settings_recovery.xml b/android/TerminalApp/res/layout/settings_recovery.xml
index 8b13ea5..3f83588 100644
--- a/android/TerminalApp/res/layout/settings_recovery.xml
+++ b/android/TerminalApp/res/layout/settings_recovery.xml
@@ -16,7 +16,7 @@
 
 <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
     android:layout_width="match_parent"
-    android:layout_height="wrap_content"
+    android:layout_height="match_parent"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     android:orientation="vertical"
     android:layout_marginEnd="24dp"
@@ -30,46 +30,98 @@
         android:textSize="48sp"
         android:layout_marginStart="24dp"
         android:layout_marginBottom="24dp"/>
-
-    <com.google.android.material.card.MaterialCardView
-        android:id="@+id/settings_recovery_reset_card"
-        app:strokeWidth="0dp"
-        app:cardCornerRadius="0dp"
-        app:checkedIcon="@null"
-        android:focusable="true"
-        android:checkable="true"
-        android:layout_height="wrap_content"
-        android:layout_width="match_parent">
-
-        <androidx.constraintlayout.widget.ConstraintLayout
+    <FrameLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+        <com.google.android.material.progressindicator.CircularProgressIndicator
+            android:id="@+id/recovery_boot_progress"
+            android:indeterminate="true"
+            android:layout_gravity="center"
+            android:visibility="invisible"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"/>
+        <LinearLayout
+            android:id="@+id/setting_recovery_card_container"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
-            android:layout_marginEnd="16dp"
-            android:layout_marginStart="24dp">
-
-            <TextView
-                android:id="@+id/settings_recovery_reset_title"
-                android:layout_width="0dp"
+            android:orientation="vertical" >
+            <!-- TODO: consider custom view for settings item -->
+            <com.google.android.material.card.MaterialCardView
+                android:id="@+id/settings_recovery_reset_card"
+                app:strokeWidth="0dp"
+                app:cardCornerRadius="0dp"
+                app:checkedIcon="@null"
+                android:focusable="true"
+                android:checkable="true"
                 android:layout_height="wrap_content"
-                android:layout_marginTop="20dp"
-                android:layout_marginStart="24dp"
-                android:textSize="20sp"
-                android:text="@string/settings_recovery_reset_title"
-                app:layout_constraintTop_toTopOf="parent"
-                app:layout_constraintBottom_toTopOf="@+id/settings_recovery_reset_sub_title"
-                app:layout_constraintStart_toStartOf="parent" />
-
-            <TextView
-                android:id="@+id/settings_recovery_reset_sub_title"
-                android:layout_width="0dp"
+                android:layout_width="match_parent">
+                <androidx.constraintlayout.widget.ConstraintLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginEnd="16dp"
+                    android:layout_marginStart="24dp">
+                    <TextView
+                        android:id="@+id/settings_recovery_reset_title"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="20dp"
+                        android:layout_marginStart="24dp"
+                        android:textSize="20sp"
+                        android:text="@string/settings_recovery_reset_title"
+                        app:layout_constraintTop_toTopOf="parent"
+                        app:layout_constraintBottom_toTopOf="@+id/settings_recovery_reset_sub_title"
+                        app:layout_constraintStart_toStartOf="parent" />
+                    <TextView
+                        android:id="@+id/settings_recovery_reset_sub_title"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:textSize="14sp"
+                        android:layout_marginBottom="20dp"
+                        android:layout_marginStart="24dp"
+                        android:text="@string/settings_recovery_reset_sub_title"
+                        app:layout_constraintTop_toBottomOf="@+id/settings_recovery_reset_title"
+                        app:layout_constraintBottom_toBottomOf="parent"
+                        app:layout_constraintStart_toStartOf="parent" />
+                </androidx.constraintlayout.widget.ConstraintLayout>
+            </com.google.android.material.card.MaterialCardView>
+            <com.google.android.material.card.MaterialCardView
+                android:id="@+id/settings_recovery_reset_backup_card"
+                app:strokeWidth="0dp"
+                app:cardCornerRadius="0dp"
+                app:checkedIcon="@null"
+                android:focusable="true"
+                android:checkable="true"
                 android:layout_height="wrap_content"
-                android:textSize="14sp"
-                android:layout_marginBottom="20dp"
-                android:layout_marginStart="24dp"
-                android:text="@string/settings_recovery_reset_sub_title"
-                app:layout_constraintTop_toBottomOf="@+id/settings_recovery_reset_title"
-                app:layout_constraintBottom_toBottomOf="parent"
-                app:layout_constraintStart_toStartOf="parent" />
-        </androidx.constraintlayout.widget.ConstraintLayout>
-    </com.google.android.material.card.MaterialCardView>
-</LinearLayout>
\ No newline at end of file
+                android:layout_width="match_parent">
+                <androidx.constraintlayout.widget.ConstraintLayout
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginEnd="16dp"
+                    android:layout_marginStart="24dp">
+                    <TextView
+                        android:id="@+id/settings_recovery_reset_backup_title"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:layout_marginTop="20dp"
+                        android:layout_marginStart="24dp"
+                        android:textSize="20sp"
+                        android:text="@string/settings_recovery_remove_backup_title"
+                        app:layout_constraintTop_toTopOf="parent"
+                        app:layout_constraintBottom_toTopOf="@+id/settings_recovery_reset_backup_sub_title"
+                        app:layout_constraintStart_toStartOf="parent" />
+                    <TextView
+                        android:id="@+id/settings_recovery_reset_backup_sub_title"
+                        android:layout_width="0dp"
+                        android:layout_height="wrap_content"
+                        android:textSize="14sp"
+                        android:layout_marginBottom="20dp"
+                        android:layout_marginStart="24dp"
+                        android:text="@string/settings_recovery_remove_backup_sub_title"
+                        app:layout_constraintTop_toBottomOf="@+id/settings_recovery_reset_backup_title"
+                        app:layout_constraintBottom_toBottomOf="parent"
+                        app:layout_constraintStart_toStartOf="parent" />
+                </androidx.constraintlayout.widget.ConstraintLayout>
+            </com.google.android.material.card.MaterialCardView>
+        </LinearLayout>
+    </FrameLayout>
+</LinearLayout>
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java
index fb75533..e18b0d1 100644
--- a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java
@@ -62,7 +62,7 @@
         mExecutorService = Executors.newCachedThreadPool();
 
         ConfigJson json = ConfigJson.from(VM_CONFIG_PATH);
-        VirtualMachineConfig config = json.toConfig(this);
+        VirtualMachineConfig config = json.toConfigBuilder(this).build();
 
         Runner runner;
         try {
diff --git a/build/debian/fai_config/files/etc/systemd/system/backup_mount.service/AVF b/build/debian/fai_config/files/etc/systemd/system/backup_mount.service/AVF
new file mode 100644
index 0000000..966df49
--- /dev/null
+++ b/build/debian/fai_config/files/etc/systemd/system/backup_mount.service/AVF
@@ -0,0 +1,14 @@
+[Unit]
+Description=Mount backup rootfs
+After=network.target
+After=virtiofs_internal.service
+
+[Service]
+Type=oneshot
+User=root
+Group=root
+ExecStart=/bin/bash -c '[ -e "/dev/vdb" ] && (mkdir -p /mnt/backup; chown 1000:100 /mnt/backup; mount /dev/vdb /mnt/backup) || (rm -rf /mnt/backup)'
+RemainAfterExit=yes
+
+[Install]
+WantedBy=multi-user.target
diff --git a/build/debian/fai_config/scripts/AVF/10-systemd b/build/debian/fai_config/scripts/AVF/10-systemd
index 1605381..a514299 100755
--- a/build/debian/fai_config/scripts/AVF/10-systemd
+++ b/build/debian/fai_config/scripts/AVF/10-systemd
@@ -9,3 +9,4 @@
 ln -s /etc/systemd/system/virtiofs.service $target/etc/systemd/system/multi-user.target.wants/virtiofs.service
 ln -s /etc/systemd/system/forwarder_guest_launcher.service $target/etc/systemd/system/multi-user.target.wants/forwarder_guest_launcher.service
 ln -s /etc/systemd/system/virtiofs_internal.service $target/etc/systemd/system/multi-user.target.wants/virtiofs_internal.service
+ln -s /etc/systemd/system/backup_mount.service $target/etc/systemd/system/multi-user.target.wants/backup_mount.service
diff --git a/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/ConfigJson.java b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/ConfigJson.java
index a259fe2..f229964 100644
--- a/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/ConfigJson.java
+++ b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/ConfigJson.java
@@ -94,21 +94,20 @@
                 : VirtualMachineConfig.DEBUG_LEVEL_NONE;
     }
 
-    /** Converts this parsed JSON into VirtualMachieConfig */
-    VirtualMachineConfig toConfig(Context context) {
+    /** Converts this parsed JSON into VirtualMachieConfig Builder */
+    VirtualMachineConfig.Builder toConfigBuilder(Context context) {
         return new VirtualMachineConfig.Builder(context)
                 .setProtectedVm(isProtected)
                 .setMemoryBytes((long) memory_mib * 1024 * 1024)
                 .setConsoleInputDevice(console_input_device)
                 .setCpuTopology(getCpuTopology())
-                .setCustomImageConfig(toCustomImageConfig(context))
+                .setCustomImageConfig(toCustomImageConfigBuilder(context).build())
                 .setDebugLevel(getDebugLevel())
                 .setVmOutputCaptured(console_out)
-                .setConnectVmConsole(connect_console)
-                .build();
+                .setConnectVmConsole(connect_console);
     }
 
-    private VirtualMachineCustomImageConfig toCustomImageConfig(Context context) {
+    VirtualMachineCustomImageConfig.Builder toCustomImageConfigBuilder(Context context) {
         VirtualMachineCustomImageConfig.Builder builder =
                 new VirtualMachineCustomImageConfig.Builder();
 
@@ -152,7 +151,7 @@
                     .filter(Objects::nonNull)
                     .forEach(builder::addSharedPath);
         }
-        return builder.build();
+        return builder;
     }
 
     private static class SharedPathJson {
diff --git a/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/InstallUtils.java b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/InstallUtils.java
index d55d268..4044fff 100644
--- a/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/InstallUtils.java
+++ b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/InstallUtils.java
@@ -43,6 +43,7 @@
     private static final String VM_CONFIG_FILENAME = "vm_config.json";
     private static final String COMPRESSED_PAYLOAD_FILENAME = "images.tar.gz";
     private static final String ROOTFS_FILENAME = "root_part";
+    private static final String BACKUP_FILENAME = "root_part_backup";
     private static final String INSTALLATION_COMPLETED_FILENAME = "completed";
     private static final String PAYLOAD_DIR = "linux";
 
@@ -54,8 +55,11 @@
         return Files.exists(getInstallationCompletedPath(context));
     }
 
-    public static void unInstall(Context context) throws IOException {
-        Files.delete(getInstallationCompletedPath(context));
+    public static void backupRootFs(Context context) throws IOException {
+        Files.move(
+                getRootfsFile(context).toPath(),
+                getBackupFile(context).toPath(),
+                StandardCopyOption.REPLACE_EXISTING);
     }
 
     public static boolean createInstalledMarker(Context context) {
@@ -91,6 +95,10 @@
         return new File(context.getFilesDir(), PAYLOAD_DIR);
     }
 
+    public static File getBackupFile(Context context) {
+        return new File(context.getFilesDir(), BACKUP_FILENAME);
+    }
+
     private static Path getInstallationCompletedPath(Context context) {
         return getInternalStorageDir(context).toPath().resolve(INSTALLATION_COMPLETED_FILENAME);
     }
diff --git a/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/VmLauncherService.java b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/VmLauncherService.java
index 846fd26..1eb558e 100644
--- a/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/VmLauncherService.java
+++ b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/VmLauncherService.java
@@ -24,6 +24,8 @@
 import android.os.ResultReceiver;
 import android.system.virtualmachine.VirtualMachine;
 import android.system.virtualmachine.VirtualMachineConfig;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig.Disk;
 import android.system.virtualmachine.VirtualMachineException;
 import android.util.Log;
 
@@ -85,7 +87,15 @@
         mExecutorService = Executors.newCachedThreadPool();
 
         ConfigJson json = ConfigJson.from(InstallUtils.getVmConfigPath(this));
-        VirtualMachineConfig config = json.toConfig(this);
+        VirtualMachineConfig.Builder configBuilder = json.toConfigBuilder(this);
+        VirtualMachineCustomImageConfig.Builder customImageConfigBuilder =
+                json.toCustomImageConfigBuilder(this);
+        File backupFile = InstallUtils.getBackupFile(this);
+        if (backupFile.exists()) {
+            customImageConfigBuilder.addDisk(Disk.RWDisk(backupFile.getPath()));
+            configBuilder.setCustomImageConfig(customImageConfigBuilder.build());
+        }
+        VirtualMachineConfig config = configBuilder.build();
 
         Runner runner;
         try {