Add LinuxInstallerApp(and Stub)

Bug: 357827587
Bug: 362897977
Test: m && flash, and then setup.sh && m LinuxInstallerApp && adb
install

Change-Id: Ib74debb1f4cf3aaf87fd17f5ff3b6df3c342b54f
diff --git a/android/FerrochromeApp/Android.bp b/android/FerrochromeApp/Android.bp
index 9f0c735..3e4ad14 100644
--- a/android/FerrochromeApp/Android.bp
+++ b/android/FerrochromeApp/Android.bp
@@ -2,17 +2,22 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
+java_defaults {
+    name: "VmPayloadInstaller",
+    init_rc: [":custom_vm_setup.rc"],
+    required: ["custom_vm_setup"],
+    // TODO(b/348113995): move this app to product partition
+    system_ext_specific: true,
+    platform_apis: true,
+    privileged: true,
+}
+
 android_app {
     name: "FerrochromeApp",
     srcs: ["java/**/*.java"],
     resource_dirs: ["res"],
-    platform_apis: true,
-    // TODO(b/348113995): move this app to product partition
-    system_ext_specific: true,
-    privileged: true,
-    init_rc: ["custom_vm_setup.rc"],
+    defaults: ["VmPayloadInstaller"],
     required: [
-        "custom_vm_setup",
         "privapp-permissions-ferrochrome.xml",
     ],
 }
@@ -24,6 +29,11 @@
     system_ext_specific: true,
 }
 
+filegroup {
+    name: "custom_vm_setup.rc",
+    srcs: ["custom_vm_setup.rc"],
+}
+
 sh_binary {
     name: "custom_vm_setup",
     src: "custom_vm_setup.sh",
diff --git a/android/FerrochromeApp/custom_vm_setup.sh b/android/FerrochromeApp/custom_vm_setup.sh
index 4dce0c7..df1a3a6 100644
--- a/android/FerrochromeApp/custom_vm_setup.sh
+++ b/android/FerrochromeApp/custom_vm_setup.sh
@@ -15,12 +15,13 @@
   cp -u ${src_dir}/vm_config.json ${dst_dir}
   chmod 666 ${dst_dir}/*
 
-  # 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
-
+  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
 }
diff --git a/android/LinuxInstaller/.gitignore b/android/LinuxInstaller/.gitignore
new file mode 100644
index 0000000..e81da29
--- /dev/null
+++ b/android/LinuxInstaller/.gitignore
@@ -0,0 +1,2 @@
+assets/*
+!assets/.gitkeep
diff --git a/android/LinuxInstaller/Android.bp b/android/LinuxInstaller/Android.bp
new file mode 100644
index 0000000..5f34c63
--- /dev/null
+++ b/android/LinuxInstaller/Android.bp
@@ -0,0 +1,34 @@
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_app {
+    name: "LinuxInstallerApp",
+    srcs: ["java/**/*.java"],
+    resource_dirs: ["res"],
+    asset_dirs: ["assets"],
+    manifest: "AndroidManifest.xml",
+    defaults: ["VmPayloadInstaller"],
+    overrides: ["LinuxInstallerAppStub"],
+    required: [
+        "privapp-permissions-linuxinstaller.xml",
+    ],
+}
+
+android_app {
+    name: "LinuxInstallerAppStub",
+    srcs: ["java/**/*.java"],
+    resource_dirs: ["res"],
+    manifest: "AndroidManifest_stub.xml",
+    defaults: ["VmPayloadInstaller"],
+    required: [
+        "privapp-permissions-linuxinstaller.xml",
+    ],
+}
+
+prebuilt_etc {
+    name: "privapp-permissions-linuxinstaller.xml",
+    src: "privapp-permissions-linuxinstaller.xml",
+    sub_dir: "permissions",
+    system_ext_specific: true,
+}
diff --git a/android/LinuxInstaller/AndroidManifest.xml b/android/LinuxInstaller/AndroidManifest.xml
new file mode 100644
index 0000000..5b10d9e
--- /dev/null
+++ b/android/LinuxInstaller/AndroidManifest.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.virtualization.linuxinstaller" >
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.CHANGE_COMPONENT_ENABLED_STATE" />
+    <queries>
+        <intent>
+            <action android:name="android.virtualization.VM_TERMINAL" />
+        </intent>
+    </queries>
+    <application
+        android:label="LinuxInstaller">
+        <activity android:name=".MainActivity"
+                  android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>
diff --git a/android/LinuxInstaller/AndroidManifest_stub.xml b/android/LinuxInstaller/AndroidManifest_stub.xml
new file mode 100644
index 0000000..49365ea
--- /dev/null
+++ b/android/LinuxInstaller/AndroidManifest_stub.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.virtualization.linuxinstaller" >
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.CHANGE_COMPONENT_ENABLED_STATE" />
+    <queries>
+        <intent>
+            <action android:name="android.virtualization.VM_TERMINAL" />
+        </intent>
+    </queries>
+    <application
+        android:label="LinuxInstaller">
+        <activity android:name=".MainActivity"
+                  android:exported="true">
+        </activity>
+    </application>
+
+</manifest>
diff --git a/android/LinuxInstaller/assets/.gitkeep b/android/LinuxInstaller/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android/LinuxInstaller/assets/.gitkeep
diff --git a/android/LinuxInstaller/java/com/android/virtualization/linuxinstaller/MainActivity.java b/android/LinuxInstaller/java/com/android/virtualization/linuxinstaller/MainActivity.java
new file mode 100644
index 0000000..1d875cb
--- /dev/null
+++ b/android/LinuxInstaller/java/com/android/virtualization/linuxinstaller/MainActivity.java
@@ -0,0 +1,207 @@
+/*
+ * 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.linuxinstaller;
+
+import android.annotation.WorkerThread;
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+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.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class MainActivity extends Activity {
+    private static final String TAG = "LinuxInstaller";
+    private static final String ACTION_VM_TERMINAL = "android.virtualization.VM_TERMINAL";
+
+    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);
+        setContentView(R.layout.activity_main);
+
+        executorService.execute(this::installLinuxImage);
+    }
+
+    private void installLinuxImage() {
+        ComponentName vmTerminalComponent = resolve(getPackageManager(), ACTION_VM_TERMINAL);
+        if (vmTerminalComponent == null) {
+            updateStatus("Failed to resolve VM terminal");
+            return;
+        }
+
+        if (!hasLocalAssets()) {
+            updateStatus("No local assets");
+            return;
+        }
+        try {
+            updateImageIfNeeded();
+        } catch (IOException e) {
+            Log.e(TAG, "failed to update image", e);
+            return;
+        }
+        updateStatus("Enabling terminal app...");
+        getPackageManager()
+                .setComponentEnabledSetting(
+                        vmTerminalComponent,
+                        PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
+                        PackageManager.DONT_KILL_APP);
+        updateStatus("Done.");
+    }
+
+    @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.custom_vm_setup.path", destDir);
+        SystemProperties.set("debug.custom_vm_setup.done", "false");
+        SystemProperties.set("debug.custom_vm_setup.start", "true");
+        while (!SystemProperties.getBoolean("debug.custom_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");
+                });
+    }
+
+    private ComponentName resolve(PackageManager pm, String action) {
+        Intent intent = new Intent(action);
+        List<ResolveInfo> resolveInfos = pm.queryIntentActivities(intent, PackageManager.MATCH_ALL);
+        if (resolveInfos.size() != 1) {
+            Log.w(
+                    TAG,
+                    "Failed to resolve activity, action=" + action + ", resolved=" + resolveInfos);
+            return null;
+        }
+        ActivityInfo activityInfo = resolveInfos.getFirst().activityInfo;
+        // MainActivityAlias shows in Launcher
+        return new ComponentName(activityInfo.packageName, activityInfo.name + "Alias");
+    }
+}
diff --git a/android/LinuxInstaller/linux_image_builder/.gitignore b/android/LinuxInstaller/linux_image_builder/.gitignore
deleted file mode 100644
index f082413..0000000
--- a/android/LinuxInstaller/linux_image_builder/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-debian.img
-ttyd
diff --git a/android/LinuxInstaller/linux_image_builder/commands b/android/LinuxInstaller/linux_image_builder/commands
index 8b2da45..4d27475 100644
--- a/android/LinuxInstaller/linux_image_builder/commands
+++ b/android/LinuxInstaller/linux_image_builder/commands
@@ -1,6 +1,6 @@
 upload init.sh:/root
 upload vsock.py:/usr/local/bin
-upload ttyd:/usr/local/bin
+upload /tmp/ttyd:/usr/local/bin
 upload ttyd.service:/etc/systemd/system
 upload vsockip.service:/etc/systemd/system
 chmod 0777:/root/init.sh
diff --git a/android/LinuxInstaller/linux_image_builder/setup.sh b/android/LinuxInstaller/linux_image_builder/setup.sh
index a9aa77d..2883e61 100755
--- a/android/LinuxInstaller/linux_image_builder/setup.sh
+++ b/android/LinuxInstaller/linux_image_builder/setup.sh
@@ -1,4 +1,29 @@
 #!/bin/bash
-wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-nocloud-arm64.raw -O debian.img
-wget  https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.aarch64 -O ttyd
-virt-customize --commands-from-file commands -a debian.img
\ No newline at end of file
+
+pushd $(dirname $0) > /dev/null
+tempdir=$(mktemp -d)
+echo Get Debian image and dependencies...
+wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-nocloud-arm64.raw -O ${tempdir}/debian.img
+wget https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.aarch64 -O ${tempdir}/ttyd
+
+echo Customize the image...
+virt-customize --commands-from-file <(sed "s|/tmp|$tempdir|g" commands) -a ${tempdir}/debian.img
+
+asset_dir=../assets/linux
+mkdir -p ${asset_dir}
+
+echo Copy files...
+
+pushd ${tempdir} > /dev/null
+tar czvS -f images.tar.gz debian.img
+popd > /dev/null
+mv ${tempdir}/images.tar.gz ${asset_dir}/images.tar.gz
+cp vm_config.json ${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}
\ No newline at end of file
diff --git a/android/LinuxInstaller/linux_image_builder/vm_config.json b/android/LinuxInstaller/linux_image_builder/vm_config.json
new file mode 100644
index 0000000..21462b8
--- /dev/null
+++ b/android/LinuxInstaller/linux_image_builder/vm_config.json
@@ -0,0 +1,19 @@
+{
+    "name": "debian",
+    "disks": [
+        {
+            "image": "/data/local/tmp/debian.img",
+            "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/android/LinuxInstaller/privapp-permissions-linuxinstaller.xml b/android/LinuxInstaller/privapp-permissions-linuxinstaller.xml
new file mode 100644
index 0000000..e46ec97
--- /dev/null
+++ b/android/LinuxInstaller/privapp-permissions-linuxinstaller.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<permissions>
+    <privapp-permissions package="com.android.virtualization.linuxinstaller">
+        <permission name="android.permission.CHANGE_COMPONENT_ENABLED_STATE"/>
+    </privapp-permissions>
+</permissions>
\ No newline at end of file
diff --git a/android/LinuxInstaller/res/layout/activity_main.xml b/android/LinuxInstaller/res/layout/activity_main.xml
new file mode 100644
index 0000000..3967167
--- /dev/null
+++ b/android/LinuxInstaller/res/layout/activity_main.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/AndroidManifest.xml b/android/TerminalApp/AndroidManifest.xml
index 27b2b46..ed9527c 100644
--- a/android/TerminalApp/AndroidManifest.xml
+++ b/android/TerminalApp/AndroidManifest.xml
@@ -11,13 +11,22 @@
         <activity android:name=".MainActivity"
                   android:screenOrientation="landscape"
                   android:configChanges="orientation|screenSize|keyboard|keyboardHidden|navigation|uiMode"
-                  android:exported="true"
-                  android:enabled="false">
+                  android:exported="true">
+            <intent-filter>
+                <action android:name="android.virtualization.VM_TERMINAL" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
+        <activity-alias
+            android:name=".MainActivityAlias"
+            android:targetActivity="com.android.virtualization.terminal.MainActivity"
+            android:exported="true"
+            android:enabled="false" >
             <intent-filter>
                 <action android:name="android.intent.action.MAIN" />
                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
-        </activity>
+        </activity-alias>
     </application>
 
 </manifest>