Install vm payload from /sdcard to /data/data/app

Bug: 373248801
Bug: 374244795
Test: build and run
Change-Id: I96124eb14bacc03365999f8d7ee31a8ff17ceb99
diff --git a/android/TerminalApp/Android.bp b/android/TerminalApp/Android.bp
index e5e8b0a..932ca76 100644
--- a/android/TerminalApp/Android.bp
+++ b/android/TerminalApp/Android.bp
@@ -26,15 +26,3 @@
         "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/generate_assets.sh b/android/TerminalApp/generate_assets.sh
deleted file mode 100755
index 4001bfd..0000000
--- a/android/TerminalApp/generate_assets.sh
+++ /dev/null
@@ -1,27 +0,0 @@
-#!/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
-image_raw_path=$(realpath $1)
-pushd $(dirname $0) > /dev/null
-tempdir=$(mktemp -d)
-asset_dir=./assets/linux
-mkdir -p ${asset_dir}
-echo Copy files...
-pushd ${tempdir} > /dev/null
-cp "${image_raw_path}" ${tempdir}
-tar czvS -f images.tar.gz $(basename ${image_raw_path})
-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
index 1c739e2..a49ea72 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
@@ -16,37 +16,20 @@
 
 package com.android.virtualization.terminal;
 
-import android.annotation.WorkerThread;
 import android.app.Activity;
+import android.os.Build;
 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 com.android.virtualization.vmlauncher.InstallUtils;
 
-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
@@ -60,115 +43,24 @@
     }
 
     private void installLinuxImage() {
-        if (!hasLocalAssets()) {
-            updateStatus("No local assets");
-            setResult(RESULT_CANCELED, null);
-            return;
+        Log.d(TAG, "installLinuxImage");
+        // Installing from sdcard is supported only in debuggable build.
+        if (Build.isDebuggable()) {
+            updateStatus("try /sdcard/linux/images.tar.gz");
+            if (InstallUtils.installImageFromExternalStorage(this)) {
+                Log.d(TAG, "success / sdcard");
+                updateStatus("image is installed from /sdcard/linux/images.tar.gz");
+                setResult(RESULT_OK);
+                finish();
+                return;
+            }
+            Log.d(TAG, "fail / sdcard");
+            updateStatus("There is no /sdcard/linux/images.tar.gz");
         }
-        try {
-            updateImageIfNeeded();
-        } catch (IOException e) {
-            Log.e(TAG, "failed to update image", e);
-            return;
-        }
-        updateStatus("Done.");
-        setResult(RESULT_OK);
+        setResult(RESULT_CANCELED, null);
         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(
                 () -> {
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
index 612da12..1659931 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
@@ -20,6 +20,7 @@
 import android.content.Context;
 import android.content.Intent;
 import android.net.http.SslError;
+import android.os.Build;
 import android.os.Bundle;
 import android.system.ErrnoException;
 import android.system.Os;
@@ -39,6 +40,7 @@
 
 import androidx.appcompat.app.AppCompatActivity;
 
+import com.android.virtualization.vmlauncher.InstallUtils;
 import com.android.virtualization.vmlauncher.VmLauncherServices;
 
 import com.google.android.material.appbar.MaterialToolbar;
@@ -77,7 +79,7 @@
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 
-        checkForUpdate();
+        boolean launchInstaller = installIfNecessary();
         try {
             // No resize for now.
             long newSizeInBytes = 0;
@@ -103,6 +105,11 @@
 
         connectToTerminalService();
         readClientCertificate();
+
+        // if installer is launched, it will be handled in onActivityResult
+        if (!launchInstaller) {
+            startVm();
+        }
     }
 
     private URL getTerminalServiceUrl() {
@@ -382,12 +389,22 @@
         }
     }
 
-    private void checkForUpdate() {
-        Intent intent = new Intent(this, InstallerActivity.class);
-        startActivityForResult(intent, REQUEST_CODE_INSTALLER);
+    private boolean installIfNecessary() {
+        // If payload from external storage exists(only for debuggable build) or there is no
+        // installed image, launch installer activity.
+        if ((Build.isDebuggable() && InstallUtils.payloadFromExternalStorageExists())
+                || !InstallUtils.isImageInstalled(this)) {
+            Intent intent = new Intent(this, InstallerActivity.class);
+            startActivityForResult(intent, REQUEST_CODE_INSTALLER);
+            return true;
+        }
+        return false;
     }
 
     private void startVm() {
+        if (!InstallUtils.isImageInstalled(this)) {
+            return;
+        }
         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
deleted file mode 100644
index ac91532..0000000
--- a/android/TerminalApp/linux_vm_setup.rc
+++ /dev/null
@@ -1,23 +0,0 @@
-# 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
deleted file mode 100644
index 6a93f6f..0000000
--- a/android/TerminalApp/linux_vm_setup.sh
+++ /dev/null
@@ -1,32 +0,0 @@
-#!/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/vm_config.json b/android/TerminalApp/vm_config.json
deleted file mode 100644
index 474e9c3..0000000
--- a/android/TerminalApp/vm_config.json
+++ /dev/null
@@ -1,21 +0,0 @@
-
-{
-    "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
-}
-
diff --git a/build/apex/Android.bp b/build/apex/Android.bp
index 4b69660..f794239 100644
--- a/build/apex/Android.bp
+++ b/build/apex/Android.bp
@@ -175,10 +175,6 @@
         "true": ["virtualizationservice.xml"],
         default: unset,
     }),
-    required: select(release_flag("RELEASE_AVF_SUPPORT_CUSTOM_VM_WITH_PARAVIRTUALIZED_DEVICES"), {
-        true: ["linux_vm_setup"],
-        default: [],
-    }),
 }
 
 apex_defaults {
diff --git a/build/debian/kokoro/gcp_ubuntu_docker/aarch64/build.sh b/build/debian/kokoro/gcp_ubuntu_docker/aarch64/build.sh
index 308661b..257b7da 100644
--- a/build/debian/kokoro/gcp_ubuntu_docker/aarch64/build.sh
+++ b/build/debian/kokoro/gcp_ubuntu_docker/aarch64/build.sh
@@ -6,7 +6,8 @@
 sudo losetup -D
 grep vmx /proc/cpuinfo || true
 sudo ./build.sh
-tar czvS -f ${KOKORO_ARTIFACTS_DIR}/images.tar.gz image.raw vm_config.json.aarch64 --transform s/vm_config.json.aarch64/vm_config.json/
+# --sparse option isn't supported in apache-commons-compress
+tar czv -f ${KOKORO_ARTIFACTS_DIR}/images.tar.gz image.raw vm_config.json.aarch64 --transform s/vm_config.json.aarch64/vm_config.json/
 
 mkdir -p ${KOKORO_ARTIFACTS_DIR}/logs
 sudo cp -r /var/log/fai/* ${KOKORO_ARTIFACTS_DIR}/logs || true
diff --git a/build/debian/vm_config.json.aarch64 b/build/debian/vm_config.json.aarch64
index 2841e6d..c9a16bf 100644
--- a/build/debian/vm_config.json.aarch64
+++ b/build/debian/vm_config.json.aarch64
@@ -2,7 +2,7 @@
     "name": "debian",
     "disks": [
         {
-            "image": "$DATA_DIR/image.raw",
+            "image": "$PAYLOAD_DIR/image.raw",
             "partitions": [],
             "writable": true
         }
diff --git a/libs/vm_launcher_lib/Android.bp b/libs/vm_launcher_lib/Android.bp
index 5267508..f47f6b6 100644
--- a/libs/vm_launcher_lib/Android.bp
+++ b/libs/vm_launcher_lib/Android.bp
@@ -13,6 +13,7 @@
     static_libs: [
         "gson",
         "debian-service-grpclib-lite",
+        "apache-commons-compress",
     ],
     libs: [
         "framework-virtualization.impl",
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
new file mode 100644
index 0000000..eb6dd77
--- /dev/null
+++ b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/InstallUtils.java
@@ -0,0 +1,141 @@
+/*
+ * Copyright (C) 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.vmlauncher;
+
+import android.content.Context;
+import android.os.Environment;
+import android.util.Log;
+
+import org.apache.commons.compress.archivers.ArchiveEntry;
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.function.Function;
+
+public class InstallUtils {
+    private static final String TAG = InstallUtils.class.getSimpleName();
+
+    private static final String VM_CONFIG_FILENAME = "vm_config.json";
+    private static final String COMPRESSED_PAYLOAD_FILENAME = "images.tar.gz";
+    private static final String PAYLOAD_DIR = "linux";
+
+    public static String getVmConfigPath(Context context) {
+        return new File(context.getFilesDir(), PAYLOAD_DIR)
+                .toPath()
+                .resolve(VM_CONFIG_FILENAME)
+                .toString();
+    }
+
+    public static boolean isImageInstalled(Context context) {
+        return Files.exists(Path.of(getVmConfigPath(context)));
+    }
+
+    private static Path getPayloadPath() {
+        File payloadDir = Environment.getExternalStoragePublicDirectory(PAYLOAD_DIR);
+        if (payloadDir == null) {
+            Log.d(TAG, "no payload dir: " + payloadDir);
+            return null;
+        }
+        Path payloadPath = payloadDir.toPath().resolve(COMPRESSED_PAYLOAD_FILENAME);
+        return payloadPath;
+    }
+
+    public static boolean payloadFromExternalStorageExists() {
+        return Files.exists(getPayloadPath());
+    }
+
+    public static boolean installImageFromExternalStorage(Context context) {
+        if (!payloadFromExternalStorageExists()) {
+            Log.d(TAG, "no artifact file from external storage");
+        }
+        Path payloadPath = getPayloadPath();
+        try (BufferedInputStream inputStream =
+                        new BufferedInputStream(Files.newInputStream(payloadPath));
+                TarArchiveInputStream tar =
+                        new TarArchiveInputStream(new GzipCompressorInputStream(inputStream))) {
+            ArchiveEntry entry;
+            Path baseDir = new File(context.getFilesDir(), PAYLOAD_DIR).toPath();
+            Files.createDirectories(baseDir);
+            while ((entry = tar.getNextEntry()) != null) {
+                Path extractTo = baseDir.resolve(entry.getName());
+                if (entry.isDirectory()) {
+                    Files.createDirectories(extractTo);
+                } else {
+                    Files.copy(tar, extractTo, StandardCopyOption.REPLACE_EXISTING);
+                }
+            }
+        } catch (IOException e) {
+            Log.e(TAG, "installation failed", e);
+            return false;
+        }
+        if (!isImageInstalled(context)) {
+            return false;
+        }
+
+        if (!resolvePathInVmConfig(context)) {
+            Log.d(TAG, "resolving path failed");
+            try {
+                Files.deleteIfExists(Path.of(getVmConfigPath(context)));
+            } catch (IOException e) {
+                return false;
+            }
+            return false;
+        }
+
+        // remove payload if installation is done.
+        try {
+            Files.deleteIfExists(payloadPath);
+        } catch (IOException e) {
+            Log.d(TAG, "failed to remove installed payload", e);
+        }
+
+        return true;
+    }
+
+    private static Function<String, String> getReplacer(Context context) {
+        Map<String, String> rules = new HashMap<>();
+        rules.put("\\$PAYLOAD_DIR", new File(context.getFilesDir(), PAYLOAD_DIR).toString());
+        return (s) -> {
+            for (Map.Entry<String, String> rule : rules.entrySet()) {
+                s = s.replaceAll(rule.getKey(), rule.getValue());
+            }
+            return s;
+        };
+    }
+
+    private static boolean resolvePathInVmConfig(Context context) {
+        try {
+            String replacedVmConfig =
+                    String.join(
+                            "\n",
+                            Files.readAllLines(Path.of(getVmConfigPath(context))).stream()
+                                    .map(getReplacer(context))
+                                    .toList());
+            Files.write(Path.of(getVmConfigPath(context)), replacedVmConfig.getBytes());
+            return true;
+        } catch (IOException e) {
+            return false;
+        }
+    }
+}
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 3d5c345..a59cc3d 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
@@ -40,8 +40,6 @@
 
 public class VmLauncherService extends Service implements DebianServiceImpl.DebianServiceCallback {
     private static final String TAG = "VmLauncherService";
-    // TODO: this path should be from outside of this service
-    private static final String VM_CONFIG_PATH = "/data/local/tmp/vm_config.json";
 
     private static final int RESULT_START = 0;
     private static final int RESULT_STOP = 1;
@@ -81,7 +79,7 @@
         }
         mExecutorService = Executors.newCachedThreadPool();
 
-        ConfigJson json = ConfigJson.from(VM_CONFIG_PATH);
+        ConfigJson json = ConfigJson.from(InstallUtils.getVmConfigPath(this));
         VirtualMachineConfig config = json.toConfig(this);
 
         Runner runner;