VmTerminalApp: Merge with LinuxInstaller

This CL includes following changes:
- Move linux_vm_setup
- Move LinuxInstaller's MainActivity as InstallerActivity
  - InstallerActivity finishes self when done
- Remove LinuxInstaller and clean up relevant codes

Bug: 369746567
Test: manually
Change-Id: I2c97e07b00e110aebe09db2760b26257ace4f7d9
diff --git a/android/TerminalApp/.gitignore b/android/TerminalApp/.gitignore
new file mode 100644
index 0000000..e81da29
--- /dev/null
+++ b/android/TerminalApp/.gitignore
@@ -0,0 +1,2 @@
+assets/*
+!assets/.gitkeep
diff --git a/android/TerminalApp/Android.bp b/android/TerminalApp/Android.bp
index 932ca76..e5e8b0a 100644
--- a/android/TerminalApp/Android.bp
+++ b/android/TerminalApp/Android.bp
@@ -26,3 +26,15 @@
         "com.android.virt",
     ],
 }
+
+filegroup {
+    name: "linux_vm_setup.rc",
+    srcs: ["linux_vm_setup.rc"],
+}
+
+sh_binary {
+    name: "linux_vm_setup",
+    src: "linux_vm_setup.sh",
+    init_rc: [":linux_vm_setup.rc"],
+    host_supported: false,
+}
diff --git a/android/TerminalApp/AndroidManifest.xml b/android/TerminalApp/AndroidManifest.xml
index bd1395a..105e454 100644
--- a/android/TerminalApp/AndroidManifest.xml
+++ b/android/TerminalApp/AndroidManifest.xml
@@ -3,6 +3,7 @@
     xmlns:tools="http://schemas.android.com/tools"
     package="com.android.virtualization.terminal">
 
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
     <uses-permission android:name="android.permission.MANAGE_VIRTUAL_MACHINE" />
     <uses-permission android:name="android.permission.USE_CUSTOM_VIRTUAL_MACHINE" />
     <uses-permission android:name="android.permission.INTERNET" />
@@ -47,6 +48,12 @@
                 android:name="${applicationId}.SplitInitializer"
                 android:value="androidx.startup" />
         </provider>
+        <activity android:name=".InstallerActivity"
+            android:exported="false">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+            </intent-filter>
+        </activity>
 
         <service
             android:name="com.android.virtualization.vmlauncher.VmLauncherService"
diff --git a/android/TerminalApp/assets/.gitkeep b/android/TerminalApp/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android/TerminalApp/assets/.gitkeep
diff --git a/android/TerminalApp/generate_assets.sh b/android/TerminalApp/generate_assets.sh
new file mode 100755
index 0000000..ff7444e
--- /dev/null
+++ b/android/TerminalApp/generate_assets.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+set -e
+
+if [ "$#" -ne 1 ]; then
+    echo "$0 <image.raw path>"
+    echo "image.raw can be built with packages/modules/Virtualization/build/debian/build.sh"
+    exit 1
+fi
+pushd $(dirname $0) > /dev/null
+tempdir=$(mktemp -d)
+asset_dir=./assets/linux
+mkdir -p ${asset_dir}
+echo Copy files...
+pushd ${tempdir} > /dev/null
+cp "$1" ${tempdir}
+tar czvS -f images.tar.gz $(basename $1)
+popd > /dev/null
+cp vm_config.json ${asset_dir}
+mv ${tempdir}/images.tar.gz ${asset_dir}
+echo Calculating hash...
+hash=$(cat ${asset_dir}/images.tar.gz ${asset_dir}/vm_config.json | sha1sum | cut -d' ' -f 1)
+echo ${hash} > ${asset_dir}/hash
+popd > /dev/null
+echo Cleaning up...
+rm -rf ${tempdir}
+
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
new file mode 100644
index 0000000..1c739e2
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
@@ -0,0 +1,179 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.virtualization.terminal;
+
+import android.annotation.WorkerThread;
+import android.app.Activity;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.SystemProperties;
+import android.text.TextUtils;
+import android.util.Log;
+import android.widget.TextView;
+
+import libcore.io.Streams;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class InstallerActivity extends Activity {
+    private static final String TAG = "LinuxInstaller";
+
+    private static final Path DEST_DIR =
+            Path.of(Environment.getExternalStorageDirectory().getPath(), "linux");
+
+    private static final String ASSET_DIR = "linux";
+    private static final String HASH_FILE_NAME = "hash";
+    private static final Path HASH_FILE = Path.of(DEST_DIR.toString(), HASH_FILE_NAME);
+
+    ExecutorService executorService = Executors.newSingleThreadExecutor();
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setResult(RESULT_CANCELED);
+
+        setContentView(R.layout.activity_installer);
+
+        executorService.execute(this::installLinuxImage);
+    }
+
+    private void installLinuxImage() {
+        if (!hasLocalAssets()) {
+            updateStatus("No local assets");
+            setResult(RESULT_CANCELED, null);
+            return;
+        }
+        try {
+            updateImageIfNeeded();
+        } catch (IOException e) {
+            Log.e(TAG, "failed to update image", e);
+            return;
+        }
+        updateStatus("Done.");
+        setResult(RESULT_OK);
+        finish();
+    }
+
+    @WorkerThread
+    private boolean hasLocalAssets() {
+        try {
+            String[] files = getAssets().list(ASSET_DIR);
+            return files != null && files.length > 0;
+        } catch (IOException e) {
+            Log.e(TAG, "there is an error during listing up assets", e);
+            return false;
+        }
+    }
+
+    @WorkerThread
+    private void updateImageIfNeeded() throws IOException {
+        if (!isUpdateNeeded()) {
+            Log.d(TAG, "No update needed.");
+            return;
+        }
+
+        try {
+            if (Files.notExists(DEST_DIR)) {
+                Files.createDirectory(DEST_DIR);
+            }
+
+            updateStatus("Copying images...");
+            String[] files = getAssets().list(ASSET_DIR);
+            for (String file : files) {
+                updateStatus(file);
+                Path dst = Path.of(DEST_DIR.toString(), file);
+                updateFile(getAssets().open(ASSET_DIR + "/" + file), dst);
+            }
+        } catch (IOException e) {
+            Log.e(TAG, "Error while updating image: " + e);
+            updateStatus("Failed to update image.");
+            throw e;
+        }
+        extractImages(DEST_DIR.toAbsolutePath().toString());
+    }
+
+    @WorkerThread
+    private void extractImages(String destDir) throws IOException {
+        updateStatus("Extracting images...");
+
+        if (TextUtils.isEmpty(destDir)) {
+            throw new RuntimeException("Internal error: destDir shouldn't be null");
+        }
+
+        SystemProperties.set("debug.linux_vm_setup.path", destDir);
+        SystemProperties.set("debug.linux_vm_setup.done", "false");
+        SystemProperties.set("debug.linux_vm_setup.start", "true");
+        while (!SystemProperties.getBoolean("debug.linux_vm_setup.done", false)) {
+            try {
+                Thread.sleep(1000);
+            } catch (InterruptedException e) {
+                Log.e(TAG, "Error while extracting image: " + e);
+                updateStatus("Failed to extract image.");
+                throw new IOException("extracting image is interrupted", e);
+            }
+        }
+    }
+
+    @WorkerThread
+    private boolean isUpdateNeeded() {
+        Path[] pathsToCheck = {DEST_DIR, HASH_FILE};
+        for (Path p : pathsToCheck) {
+            if (Files.notExists(p)) {
+                Log.d(TAG, p.toString() + " does not exist.");
+                return true;
+            }
+        }
+
+        try {
+            String installedHash = readAll(new FileInputStream(HASH_FILE.toFile()));
+            String updatedHash = readAll(getAssets().open(ASSET_DIR + "/" + HASH_FILE_NAME));
+            if (installedHash.equals(updatedHash)) {
+                return false;
+            }
+            Log.d(TAG, "Hash mismatch. Installed: " + installedHash + "  Updated: " + updatedHash);
+        } catch (IOException e) {
+            Log.e(TAG, "Error while checking hash: " + e);
+        }
+        return true;
+    }
+
+    private static String readAll(InputStream input) throws IOException {
+        return Streams.readFully(new InputStreamReader(input)).strip();
+    }
+
+    private static void updateFile(InputStream input, Path path) throws IOException {
+        try (input) {
+            Files.copy(input, path, StandardCopyOption.REPLACE_EXISTING);
+        }
+    }
+
+    private void updateStatus(String line) {
+        runOnUiThread(
+                () -> {
+                    TextView statusView = findViewById(R.id.status_txt_view);
+                    statusView.append(line + "\n");
+                });
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
index 3aca929..bd35f51 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
@@ -66,6 +66,8 @@
     private static final String TAG = "VmTerminalApp";
     private static final String VM_ADDR = "192.168.0.2";
     private static final int TTYD_PORT = 7681;
+    private static final int REQUEST_CODE_INSTALLER = 0x33;
+
     private X509Certificate[] mCertificates;
     private PrivateKey mPrivateKey;
     private WebView mWebView;
@@ -75,6 +77,7 @@
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 
+        checkForUpdate();
         try {
             // No resize for now.
             long newSizeInBytes = 0;
@@ -85,10 +88,6 @@
                     .show();
         }
 
-        Toast.makeText(this, R.string.vm_creation_message, Toast.LENGTH_SHORT).show();
-        android.os.Trace.beginAsyncSection("executeTerminal", 0);
-        VmLauncherServices.startVmLauncherService(this, this);
-
         setContentView(R.layout.activity_headless);
 
         MaterialToolbar toolbar = (MaterialToolbar) findViewById(R.id.toolbar);
@@ -369,4 +368,28 @@
     public void onTouchExplorationStateChanged(boolean enabled) {
         connectToTerminalService();
     }
+
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+        super.onActivityResult(requestCode, resultCode, data);
+
+        if (requestCode == REQUEST_CODE_INSTALLER) {
+            if (resultCode != RESULT_OK) {
+                Log.e(TAG, "Failed to start VM. Installer returned error.");
+                finish();
+            }
+            startVm();
+        }
+    }
+
+    private void checkForUpdate() {
+        Intent intent = new Intent(this, InstallerActivity.class);
+        startActivityForResult(intent, REQUEST_CODE_INSTALLER);
+    }
+
+    private void startVm() {
+        Toast.makeText(this, R.string.vm_creation_message, Toast.LENGTH_SHORT).show();
+        android.os.Trace.beginAsyncSection("executeTerminal", 0);
+        VmLauncherServices.startVmLauncherService(this, this);
+    }
 }
diff --git a/android/TerminalApp/linux_vm_setup.rc b/android/TerminalApp/linux_vm_setup.rc
new file mode 100644
index 0000000..ac91532
--- /dev/null
+++ b/android/TerminalApp/linux_vm_setup.rc
@@ -0,0 +1,23 @@
+# Copyright 2024 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+service linux_vm_setup /system/bin/linux_vm_setup
+    user shell
+    group shell media_rw
+    disabled
+    oneshot
+    seclabel u:r:shell:s0
+
+on property:debug.linux_vm_setup.start=true
+    start linux_vm_setup
diff --git a/android/TerminalApp/linux_vm_setup.sh b/android/TerminalApp/linux_vm_setup.sh
new file mode 100644
index 0000000..6a93f6f
--- /dev/null
+++ b/android/TerminalApp/linux_vm_setup.sh
@@ -0,0 +1,32 @@
+#!/system/bin/sh
+
+function round_up() {
+  num=$1
+  div=$2
+  echo $((( (( ${num} / ${div} ) + 1) * ${div} )))
+}
+
+function install() {
+  src_dir=$(getprop debug.linux_vm_setup.path)
+  src_dir=${src_dir/#\/storage\/emulated\//\/data\/media\/}
+  dst_dir=/data/local/tmp/
+
+  cat $(find ${src_dir} -name "images.tar.gz*" | sort) | tar xz -C ${dst_dir}
+  cp -u ${src_dir}/vm_config.json ${dst_dir}
+  chmod 666 ${dst_dir}/*
+
+  if [ -f ${dst_dir}state.img ]; then
+    # increase the size of state.img to the multiple of 4096
+    num_blocks=$(du -b -K ${dst_dir}state.img | cut -f 1)
+    required_num_blocks=$(round_up ${num_blocks} 4)
+    additional_blocks=$((( ${required_num_blocks} - ${num_blocks} )))
+    dd if=/dev/zero bs=512 count=${additional_blocks} >> ${dst_dir}state.img
+  fi
+  rm ${src_dir}/images.tar.gz*
+  rm ${src_dir}/vm_config.json
+}
+
+setprop debug.linux_vm_setup.done false
+install
+setprop debug.linux_vm_setup.start false
+setprop debug.linux_vm_setup.done true
diff --git a/android/TerminalApp/res/layout/activity_installer.xml b/android/TerminalApp/res/layout/activity_installer.xml
new file mode 100644
index 0000000..3967167
--- /dev/null
+++ b/android/TerminalApp/res/layout/activity_installer.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:fitsSystemWindows="true"
+    android:paddingLeft="16dp"
+    android:paddingRight="16dp">
+  <TextView
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:textSize="28sp"
+      android:id="@+id/status_txt_view"/>
+
+</RelativeLayout>
diff --git a/android/TerminalApp/vm_config.json b/android/TerminalApp/vm_config.json
new file mode 100644
index 0000000..474e9c3
--- /dev/null
+++ b/android/TerminalApp/vm_config.json
@@ -0,0 +1,21 @@
+
+{
+    "name": "debian",
+    "disks": [
+        {
+            "image": "/data/local/tmp/image.raw",
+            "partitions": [],
+            "writable": true
+        }
+    ],
+    "protected": false,
+    "cpu_topology": "match_host",
+    "platform_version": "~1.0",
+    "memory_mib": 4096,
+    "debuggable": true,
+    "console_out": true,
+    "connect_console": true,
+    "console_input_device": "ttyS0",
+    "network": true
+}
+