Unite vm_launcher_lib into VmTerminalApp

Bug: 379800648
Test: build and run terminal app
Change-Id: I9ad7d68ec8adc0cb787a70169ea70f32e4bdd3b2
diff --git a/android/TerminalApp/Android.bp b/android/TerminalApp/Android.bp
index 3b5f9b8..2711af0 100644
--- a/android/TerminalApp/Android.bp
+++ b/android/TerminalApp/Android.bp
@@ -11,15 +11,22 @@
     resource_dirs: ["res"],
     asset_dirs: ["assets"],
     static_libs: [
-        "VmTerminalApp.aidl-java",
-        "vm_launcher_lib",
         "androidx-constraintlayout_constraintlayout",
-        "com.google.android.material_material",
         "androidx.window_window",
+        "apache-commons-compress",
+        "com.google.android.material_material",
+        "debian-service-grpclib-lite",
+        "gson",
+        "VmTerminalApp.aidl-java",
     ],
     jni_libs: [
         "libforwarder_host_jni",
     ],
+    libs: [
+        "androidx.annotation_annotation",
+        "framework-virtualization.impl",
+        "framework-annotations-lib",
+    ],
     use_embedded_native_libs: true,
     platform_apis: true,
     privileged: true,
diff --git a/android/TerminalApp/AndroidManifest.xml b/android/TerminalApp/AndroidManifest.xml
index b74b8b0..dad07ee 100644
--- a/android/TerminalApp/AndroidManifest.xml
+++ b/android/TerminalApp/AndroidManifest.xml
@@ -76,7 +76,7 @@
             android:stopWithTask="true" />
 
         <service
-            android:name="com.android.virtualization.vmlauncher.VmLauncherService"
+            android:name=".VmLauncherService"
             android:exported="false"
             android:foregroundServiceType="specialUse"
             android:stopWithTask="true" >
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ConfigJson.java b/android/TerminalApp/java/com/android/virtualization/terminal/ConfigJson.java
new file mode 100644
index 0000000..e1342e9
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ConfigJson.java
@@ -0,0 +1,309 @@
+/*
+ * 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.terminal;
+
+import android.content.Context;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.graphics.Rect;
+import android.os.Environment;
+import android.system.virtualmachine.VirtualMachineConfig;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig.AudioConfig;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig.Disk;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig.DisplayConfig;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig.GpuConfig;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig.Partition;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig.SharedPath;
+import android.util.DisplayMetrics;
+import android.view.WindowManager;
+import android.view.WindowMetrics;
+
+import com.google.gson.Gson;
+import com.google.gson.annotations.SerializedName;
+
+import java.io.FileReader;
+import java.util.Arrays;
+import java.util.Objects;
+
+/** This class and its inner classes model vm_config.json. */
+class ConfigJson {
+    private static final boolean DEBUG = true;
+
+    private ConfigJson() {}
+
+    @SerializedName("protected")
+    private boolean isProtected;
+
+    private String name;
+    private String cpu_topology;
+    private String platform_version;
+    private int memory_mib = 1024;
+    private String console_input_device;
+    private String bootloader;
+    private String kernel;
+    private String initrd;
+    private String params;
+    private boolean debuggable;
+    private boolean console_out;
+    private boolean connect_console;
+    private boolean network;
+    private InputJson input;
+    private AudioJson audio;
+    private DiskJson[] disks;
+    private SharedPathJson[] sharedPath;
+    private DisplayJson display;
+    private GpuJson gpu;
+
+    /** Parses JSON file at jsonPath */
+    static ConfigJson from(String jsonPath) {
+        try (FileReader r = new FileReader(jsonPath)) {
+            return new Gson().fromJson(r, ConfigJson.class);
+        } catch (Exception e) {
+            throw new RuntimeException("Failed to parse " + jsonPath, e);
+        }
+    }
+
+    private int getCpuTopology() {
+        switch (cpu_topology) {
+            case "one_cpu":
+                return VirtualMachineConfig.CPU_TOPOLOGY_ONE_CPU;
+            case "match_host":
+                return VirtualMachineConfig.CPU_TOPOLOGY_MATCH_HOST;
+            default:
+                throw new RuntimeException("invalid cpu topology: " + cpu_topology);
+        }
+    }
+
+    private int getDebugLevel() {
+        return debuggable
+                ? VirtualMachineConfig.DEBUG_LEVEL_FULL
+                : VirtualMachineConfig.DEBUG_LEVEL_NONE;
+    }
+
+    /** 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(toCustomImageConfigBuilder(context).build())
+                .setDebugLevel(getDebugLevel())
+                .setVmOutputCaptured(console_out)
+                .setConnectVmConsole(connect_console);
+    }
+
+    VirtualMachineCustomImageConfig.Builder toCustomImageConfigBuilder(Context context) {
+        VirtualMachineCustomImageConfig.Builder builder =
+                new VirtualMachineCustomImageConfig.Builder();
+
+        builder.setName(name)
+                .setBootloaderPath(bootloader)
+                .setKernelPath(kernel)
+                .setInitrdPath(initrd)
+                .useNetwork(network);
+
+        if (input != null) {
+            builder.useTouch(input.touchscreen)
+                    .useKeyboard(input.keyboard)
+                    .useMouse(input.mouse)
+                    .useTrackpad(input.trackpad)
+                    .useSwitches(input.switches);
+        }
+
+        if (audio != null) {
+            builder.setAudioConfig(audio.toConfig());
+        }
+
+        if (display != null) {
+            builder.setDisplayConfig(display.toConfig(context));
+        }
+
+        if (gpu != null) {
+            builder.setGpuConfig(gpu.toConfig());
+        }
+
+        if (params != null) {
+            Arrays.stream(params.split(" ")).forEach(builder::addParam);
+        }
+
+        if (disks != null) {
+            Arrays.stream(disks).map(d -> d.toConfig()).forEach(builder::addDisk);
+        }
+
+        if (sharedPath != null) {
+            Arrays.stream(sharedPath)
+                    .map(d -> d.toConfig(context))
+                    .filter(Objects::nonNull)
+                    .forEach(builder::addSharedPath);
+        }
+        return builder;
+    }
+
+    private static class SharedPathJson {
+        private SharedPathJson() {}
+
+        private String sharedPath;
+        private static final int GUEST_UID = 1000;
+        private static final int GUEST_GID = 100;
+
+        private SharedPath toConfig(Context context) {
+            try {
+                int terminalUid = getTerminalUid(context);
+                if (sharedPath.contains("emulated")) {
+                    if (Environment.isExternalStorageManager()) {
+                        int currentUserId = context.getUserId();
+                        String path = sharedPath + "/" + currentUserId + "/Download";
+                        return new SharedPath(
+                                path,
+                                terminalUid,
+                                terminalUid,
+                                GUEST_UID,
+                                GUEST_GID,
+                                0007,
+                                "android",
+                                "android");
+                    }
+                    return null;
+                }
+                return new SharedPath(
+                        sharedPath, terminalUid, terminalUid, 0, 0, 0007, "internal", "internal");
+            } catch (NameNotFoundException e) {
+                return null;
+            }
+        }
+
+        private int getTerminalUid(Context context) throws NameNotFoundException {
+            return context.getPackageManager()
+                    .getPackageUidAsUser(context.getPackageName(), context.getUserId());
+        }
+    }
+
+    private static class InputJson {
+        private InputJson() {}
+
+        private boolean touchscreen;
+        private boolean keyboard;
+        private boolean mouse;
+        private boolean switches;
+        private boolean trackpad;
+    }
+
+    private static class AudioJson {
+        private AudioJson() {}
+
+        private boolean microphone;
+        private boolean speaker;
+
+        private AudioConfig toConfig() {
+            return new AudioConfig.Builder()
+                    .setUseMicrophone(microphone)
+                    .setUseSpeaker(speaker)
+                    .build();
+        }
+    }
+
+    private static class DiskJson {
+        private DiskJson() {}
+
+        private boolean writable;
+        private String image;
+        private PartitionJson[] partitions;
+
+        private Disk toConfig() {
+            Disk d = writable ? Disk.RWDisk(image) : Disk.RODisk(image);
+            for (PartitionJson pj : partitions) {
+                boolean writable = this.writable && pj.writable;
+                d.addPartition(new Partition(pj.label, pj.path, writable, pj.guid));
+            }
+            return d;
+        }
+    }
+
+    private static class PartitionJson {
+        private PartitionJson() {}
+
+        private boolean writable;
+        private String label;
+        private String path;
+        private String guid;
+    }
+
+    private static class DisplayJson {
+        private DisplayJson() {}
+
+        private float scale;
+        private int refresh_rate;
+        private int width_pixels;
+        private int height_pixels;
+
+        private DisplayConfig toConfig(Context context) {
+            WindowManager wm = context.getSystemService(WindowManager.class);
+            WindowMetrics metrics = wm.getCurrentWindowMetrics();
+            Rect dispBounds = metrics.getBounds();
+
+            int width = width_pixels > 0 ? width_pixels : dispBounds.right;
+            int height = height_pixels > 0 ? height_pixels : dispBounds.bottom;
+
+            int dpi = (int) (DisplayMetrics.DENSITY_DEFAULT * metrics.getDensity());
+            if (scale > 0.0f) {
+                dpi = (int) (dpi * scale);
+            }
+
+            int refreshRate = (int) context.getDisplay().getRefreshRate();
+            if (this.refresh_rate != 0) {
+                refreshRate = this.refresh_rate;
+            }
+
+            return new DisplayConfig.Builder()
+                    .setWidth(width)
+                    .setHeight(height)
+                    .setHorizontalDpi(dpi)
+                    .setVerticalDpi(dpi)
+                    .setRefreshRate(refreshRate)
+                    .build();
+        }
+    }
+
+    private static class GpuJson {
+        private GpuJson() {}
+
+        private String backend;
+        private String pci_address;
+        private String renderer_features;
+        private boolean renderer_use_egl = true;
+        private boolean renderer_use_gles = true;
+        private boolean renderer_use_glx = false;
+        private boolean renderer_use_surfaceless = true;
+        private boolean renderer_use_vulkan = false;
+        private String[] context_types;
+
+        private GpuConfig toConfig() {
+            return new GpuConfig.Builder()
+                    .setBackend(backend)
+                    .setPciAddress(pci_address)
+                    .setRendererFeatures(renderer_features)
+                    .setRendererUseEgl(renderer_use_egl)
+                    .setRendererUseGles(renderer_use_gles)
+                    .setRendererUseGlx(renderer_use_glx)
+                    .setRendererUseSurfaceless(renderer_use_surfaceless)
+                    .setRendererUseVulkan(renderer_use_vulkan)
+                    .setContextTypes(context_types)
+                    .build();
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.java b/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.java
new file mode 100644
index 0000000..0b65cf6
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/DebianServiceImpl.java
@@ -0,0 +1,170 @@
+/*
+ * 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.terminal;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import android.util.Log;
+
+import androidx.annotation.Keep;
+
+import com.android.virtualization.terminal.proto.DebianServiceGrpc;
+import com.android.virtualization.terminal.proto.ForwardingRequestItem;
+import com.android.virtualization.terminal.proto.IpAddr;
+import com.android.virtualization.terminal.proto.QueueOpeningRequest;
+import com.android.virtualization.terminal.proto.ReportVmActivePortsRequest;
+import com.android.virtualization.terminal.proto.ReportVmActivePortsResponse;
+import com.android.virtualization.terminal.proto.ReportVmIpAddrResponse;
+
+import io.grpc.stub.StreamObserver;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+final class DebianServiceImpl extends DebianServiceGrpc.DebianServiceImplBase {
+    public static final String TAG = "DebianService";
+    private static final String PREFERENCE_FILE_KEY =
+            "com.android.virtualization.terminal.PREFERENCE_FILE_KEY";
+    private static final String PREFERENCE_FORWARDING_PORTS = "PREFERENCE_FORWARDING_PORTS";
+    private static final String PREFERENCE_FORWARDING_PORT_IS_ENABLED_PREFIX =
+            "PREFERENCE_FORWARDING_PORT_IS_ENABLED_";
+
+    private final Context mContext;
+    private final SharedPreferences mSharedPref;
+    private SharedPreferences.OnSharedPreferenceChangeListener mPortForwardingListener;
+    private final DebianServiceCallback mCallback;
+
+    static {
+        System.loadLibrary("forwarder_host_jni");
+    }
+
+    DebianServiceImpl(Context context, DebianServiceCallback callback) {
+        super();
+        mCallback = callback;
+        mContext = context;
+        mSharedPref = mContext.getSharedPreferences(PREFERENCE_FILE_KEY, Context.MODE_PRIVATE);
+    }
+
+    @Override
+    public void reportVmActivePorts(
+            ReportVmActivePortsRequest request,
+            StreamObserver<ReportVmActivePortsResponse> responseObserver) {
+        Log.d(DebianServiceImpl.TAG, "reportVmActivePorts: " + request.toString());
+
+        SharedPreferences.Editor editor = mSharedPref.edit();
+        Set<String> ports = new HashSet<>();
+        for (int port : request.getPortsList()) {
+            ports.add(Integer.toString(port));
+            if (!mSharedPref.contains(
+                    PREFERENCE_FORWARDING_PORT_IS_ENABLED_PREFIX + Integer.toString(port))) {
+                editor.putBoolean(
+                        PREFERENCE_FORWARDING_PORT_IS_ENABLED_PREFIX + Integer.toString(port),
+                        false);
+            }
+        }
+        editor.putStringSet(PREFERENCE_FORWARDING_PORTS, ports);
+        editor.apply();
+
+        ReportVmActivePortsResponse reply =
+                ReportVmActivePortsResponse.newBuilder().setSuccess(true).build();
+        responseObserver.onNext(reply);
+        responseObserver.onCompleted();
+    }
+
+    @Override
+    public void reportVmIpAddr(
+            IpAddr request, StreamObserver<ReportVmIpAddrResponse> responseObserver) {
+        Log.d(DebianServiceImpl.TAG, "reportVmIpAddr: " + request.toString());
+        mCallback.onIpAddressAvailable(request.getAddr());
+        ReportVmIpAddrResponse reply = ReportVmIpAddrResponse.newBuilder().setSuccess(true).build();
+        responseObserver.onNext(reply);
+        responseObserver.onCompleted();
+    }
+
+    @Override
+    public void openForwardingRequestQueue(
+            QueueOpeningRequest request, StreamObserver<ForwardingRequestItem> responseObserver) {
+        Log.d(DebianServiceImpl.TAG, "OpenForwardingRequestQueue");
+        mPortForwardingListener =
+                new SharedPreferences.OnSharedPreferenceChangeListener() {
+                    @Override
+                    public void onSharedPreferenceChanged(
+                            SharedPreferences sharedPreferences, String key) {
+                        if (key.startsWith(PREFERENCE_FORWARDING_PORT_IS_ENABLED_PREFIX)
+                                || key.equals(PREFERENCE_FORWARDING_PORTS)) {
+                            updateListeningPorts();
+                        }
+                    }
+                };
+        mSharedPref.registerOnSharedPreferenceChangeListener(mPortForwardingListener);
+        updateListeningPorts();
+        runForwarderHost(request.getCid(), new ForwarderHostCallback(responseObserver));
+        responseObserver.onCompleted();
+    }
+
+    @Keep
+    private static class ForwarderHostCallback {
+        private StreamObserver<ForwardingRequestItem> mResponseObserver;
+
+        ForwarderHostCallback(StreamObserver<ForwardingRequestItem> responseObserver) {
+            mResponseObserver = responseObserver;
+        }
+
+        private void onForwardingRequestReceived(int guestTcpPort, int vsockPort) {
+            ForwardingRequestItem item =
+                    ForwardingRequestItem.newBuilder()
+                            .setGuestTcpPort(guestTcpPort)
+                            .setVsockPort(vsockPort)
+                            .build();
+            mResponseObserver.onNext(item);
+        }
+    }
+
+    private static native void runForwarderHost(int cid, ForwarderHostCallback callback);
+
+    private static native void terminateForwarderHost();
+
+    void killForwarderHost() {
+        Log.d(DebianServiceImpl.TAG, "Stopping port forwarding");
+        if (mPortForwardingListener != null) {
+            mSharedPref.unregisterOnSharedPreferenceChangeListener(mPortForwardingListener);
+            terminateForwarderHost();
+        }
+    }
+
+    private static native void updateListeningPorts(int[] ports);
+
+    private void updateListeningPorts() {
+        updateListeningPorts(
+                mSharedPref
+                        .getStringSet(PREFERENCE_FORWARDING_PORTS, Collections.emptySet())
+                        .stream()
+                        .filter(
+                                port ->
+                                        mSharedPref.getBoolean(
+                                                PREFERENCE_FORWARDING_PORT_IS_ENABLED_PREFIX + port,
+                                                false))
+                        .map(Integer::valueOf)
+                        .mapToInt(Integer::intValue)
+                        .toArray());
+    }
+
+    protected interface DebianServiceCallback {
+        void onIpAddressAvailable(String ipAddr);
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallUtils.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallUtils.java
new file mode 100644
index 0000000..b17e636
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallUtils.java
@@ -0,0 +1,194 @@
+/*
+ * 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.terminal;
+
+import android.content.Context;
+import android.os.Environment;
+import android.os.FileUtils;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+
+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.FileNotFoundException;
+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 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";
+
+    public static String getVmConfigPath(Context context) {
+        return getInternalStorageDir(context).toPath().resolve(VM_CONFIG_FILENAME).toString();
+    }
+
+    public static boolean isImageInstalled(Context context) {
+        return Files.exists(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) {
+        try {
+            File file = new File(getInstallationCompletedPath(context).toString());
+            return file.createNewFile();
+        } catch (IOException e) {
+            Log.e(TAG, "Failed to mark install completed", e);
+            return false;
+        }
+    }
+
+    @VisibleForTesting
+    public static void deleteInstallation(Context context) {
+        FileUtils.deleteContentsAndDir(getInternalStorageDir(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 File getInternalStorageDir(Context context) {
+        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);
+    }
+
+    public static boolean installImageFromExternalStorage(Context context) {
+        if (!payloadFromExternalStorageExists()) {
+            Log.d(TAG, "no artifact file from external storage");
+            return false;
+        }
+        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 (!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);
+        }
+
+        // Create marker for installation done.
+        return createInstalledMarker(context);
+    }
+
+    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());
+        rules.put("\\$USER_ID", String.valueOf(context.getUserId()));
+        rules.put("\\$PACKAGE_NAME", context.getPackageName());
+        String appDataDir = context.getDataDir().toString();
+        // TODO: remove this hack
+        if (context.getUserId() == 0) {
+            appDataDir = "/data/data/" + context.getPackageName();
+        }
+        rules.put("\\$APP_DATA_DIR", appDataDir);
+        return (s) -> {
+            for (Map.Entry<String, String> rule : rules.entrySet()) {
+                s = s.replaceAll(rule.getKey(), rule.getValue());
+            }
+            return s;
+        };
+    }
+
+    public 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;
+        }
+    }
+
+    public static File getRootfsFile(Context context) throws FileNotFoundException {
+        File file = new File(getInternalStorageDir(context), ROOTFS_FILENAME);
+        if (!file.exists()) {
+            Log.d(TAG, file.getAbsolutePath() + " - file not found");
+            throw new FileNotFoundException("File not found: " + ROOTFS_FILENAME);
+        }
+        return file;
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
index 83c6b4c..a1d4cb6 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
@@ -35,7 +35,6 @@
 import android.widget.TextView;
 
 import com.android.internal.annotations.VisibleForTesting;
-import com.android.virtualization.vmlauncher.InstallUtils;
 
 import com.google.android.material.progressindicator.LinearProgressIndicator;
 import com.google.android.material.snackbar.Snackbar;
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
index f97f16f..f839c64 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
@@ -29,7 +29,6 @@
 import androidx.annotation.Nullable;
 
 import com.android.internal.annotations.GuardedBy;
-import com.android.virtualization.vmlauncher.InstallUtils;
 
 import org.apache.commons.compress.archivers.ArchiveEntry;
 import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/Logger.java b/android/TerminalApp/java/com/android/virtualization/terminal/Logger.java
new file mode 100644
index 0000000..2c0149e
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/Logger.java
@@ -0,0 +1,87 @@
+/*
+ * 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.terminal;
+
+import android.system.virtualmachine.VirtualMachine;
+import android.system.virtualmachine.VirtualMachineConfig;
+import android.system.virtualmachine.VirtualMachineException;
+import android.util.Log;
+
+import libcore.io.Streams;
+
+import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Forwards VM's console output to a file on the Android side, and VM's log output to Android logd.
+ */
+class Logger {
+    private Logger() {}
+
+    static void setup(VirtualMachine vm, Path path, ExecutorService executor) {
+        if (vm.getConfig().getDebugLevel() != VirtualMachineConfig.DEBUG_LEVEL_FULL) {
+            return;
+        }
+
+        try {
+            InputStream console = vm.getConsoleOutput();
+            OutputStream file = Files.newOutputStream(path, StandardOpenOption.CREATE);
+            executor.submit(() -> Streams.copy(console, new LineBufferedOutputStream(file)));
+
+            InputStream log = vm.getLogOutput();
+            executor.submit(() -> writeToLogd(log, vm.getName()));
+        } catch (VirtualMachineException | IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static boolean writeToLogd(InputStream input, String vmName) throws IOException {
+        BufferedReader reader = new BufferedReader(new InputStreamReader(input));
+        String line;
+        while ((line = reader.readLine()) != null && !Thread.interrupted()) {
+            Log.d(vmName, line);
+        }
+        // TODO: find out why javac complains when the return type of this method is void. It
+        // (incorrectly?) thinks that IOException should be caught inside the lambda.
+        return true;
+    }
+
+    private static class LineBufferedOutputStream extends BufferedOutputStream {
+        LineBufferedOutputStream(OutputStream out) {
+            super(out);
+        }
+
+        @Override
+        public void write(byte[] buf, int off, int len) throws IOException {
+            super.write(buf, off, len);
+            for (int i = 0; i < len; ++i) {
+                if (buf[off + i] == '\n') {
+                    flush();
+                    break;
+                }
+            }
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
index 8d03a72..eb0e7e2 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
@@ -54,9 +54,6 @@
 import androidx.activity.result.contract.ActivityResultContracts;
 
 import com.android.internal.annotations.VisibleForTesting;
-import com.android.virtualization.vmlauncher.InstallUtils;
-import com.android.virtualization.vmlauncher.VmLauncherService;
-import com.android.virtualization.vmlauncher.VmLauncherServices;
 
 import com.google.android.material.appbar.MaterialToolbar;
 
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/Runner.java b/android/TerminalApp/java/com/android/virtualization/terminal/Runner.java
new file mode 100644
index 0000000..a2247b1
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/Runner.java
@@ -0,0 +1,114 @@
+/*
+ * 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.terminal;
+
+import android.content.Context;
+import android.system.virtualmachine.VirtualMachine;
+import android.system.virtualmachine.VirtualMachineCallback;
+import android.system.virtualmachine.VirtualMachineConfig;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig;
+import android.system.virtualmachine.VirtualMachineException;
+import android.system.virtualmachine.VirtualMachineManager;
+import android.util.Log;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ForkJoinPool;
+
+/** Utility class for creating a VM and waiting for it to finish. */
+class Runner {
+    private static final String TAG = Runner.class.getSimpleName();
+    private final VirtualMachine mVirtualMachine;
+    private final Callback mCallback;
+
+    private Runner(VirtualMachine vm, Callback cb) {
+        mVirtualMachine = vm;
+        mCallback = cb;
+    }
+
+    /** Create a virtual machine of the given config, under the given context. */
+    static Runner create(Context context, VirtualMachineConfig config)
+            throws VirtualMachineException {
+        // context may already be the app context, but calling this again is not harmful.
+        // See b/359439878 on why vmm should be obtained from the app context.
+        Context appContext = context.getApplicationContext();
+        VirtualMachineManager vmm = appContext.getSystemService(VirtualMachineManager.class);
+        VirtualMachineCustomImageConfig customConfig = config.getCustomImageConfig();
+        if (customConfig == null) {
+            throw new RuntimeException("CustomImageConfig is missing");
+        }
+
+        String name = customConfig.getName();
+        if (name == null || name.isEmpty()) {
+            throw new RuntimeException("Virtual machine's name is missing in the config");
+        }
+
+        VirtualMachine vm = vmm.getOrCreate(name, config);
+        try {
+            vm.setConfig(config);
+        } catch (VirtualMachineException e) {
+            vmm.delete(name);
+            vm = vmm.create(name, config);
+            Log.w(TAG, "Re-creating virtual machine (" + name + ")", e);
+        }
+
+        Callback cb = new Callback();
+        vm.setCallback(ForkJoinPool.commonPool(), cb);
+        vm.run();
+        return new Runner(vm, cb);
+    }
+
+    /** Give access to the underlying VirtualMachine object. */
+    VirtualMachine getVm() {
+        return mVirtualMachine;
+    }
+
+    /** Get future about VM's exit status. */
+    CompletableFuture<Boolean> getExitStatus() {
+        return mCallback.mFinishedSuccessfully;
+    }
+
+    private static class Callback implements VirtualMachineCallback {
+        final CompletableFuture<Boolean> mFinishedSuccessfully = new CompletableFuture<>();
+
+        @Override
+        public void onPayloadStarted(VirtualMachine vm) {
+            // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+        }
+
+        @Override
+        public void onPayloadReady(VirtualMachine vm) {
+            // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+        }
+
+        @Override
+        public void onPayloadFinished(VirtualMachine vm, int exitCode) {
+            // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+        }
+
+        @Override
+        public void onError(VirtualMachine vm, int errorCode, String message) {
+            Log.e(TAG, "Error from VM. code: " + errorCode + " (" + message + ")");
+            mFinishedSuccessfully.complete(false);
+        }
+
+        @Override
+        public void onStopped(VirtualMachine vm, int reason) {
+            Log.d(TAG, "VM stopped. Reason: " + reason);
+            mFinishedSuccessfully.complete(true);
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt
index 7ccce9c..817808f 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt
@@ -27,7 +27,6 @@
 import android.widget.TextView
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.view.isVisible
-import com.android.virtualization.vmlauncher.InstallUtils
 import com.google.android.material.button.MaterialButton
 import com.google.android.material.slider.Slider
 import java.util.regex.Pattern
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
index e2bb28f..ef76e03 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
@@ -22,7 +22,6 @@
 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
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java
new file mode 100644
index 0000000..25afcb7
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java
@@ -0,0 +1,227 @@
+/*
+ * 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.terminal;
+
+import android.app.Notification;
+import android.app.Service;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.IBinder;
+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;
+
+import io.grpc.Grpc;
+import io.grpc.InsecureServerCredentials;
+import io.grpc.Metadata;
+import io.grpc.Server;
+import io.grpc.ServerCall;
+import io.grpc.ServerCallHandler;
+import io.grpc.ServerInterceptor;
+import io.grpc.Status;
+import io.grpc.okhttp.OkHttpServerBuilder;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.nio.file.Path;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class VmLauncherService extends Service implements DebianServiceImpl.DebianServiceCallback {
+    public static final String EXTRA_NOTIFICATION = "EXTRA_NOTIFICATION";
+    private static final String TAG = "VmLauncherService";
+
+    private static final int RESULT_START = 0;
+    private static final int RESULT_STOP = 1;
+    private static final int RESULT_ERROR = 2;
+    private static final int RESULT_IPADDR = 3;
+    private static final String KEY_VM_IP_ADDR = "ip_addr";
+
+    private ExecutorService mExecutorService;
+    private VirtualMachine mVirtualMachine;
+    private ResultReceiver mResultReceiver;
+    private Server mServer;
+    private DebianServiceImpl mDebianService;
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return null;
+    }
+
+    private void startForeground(Notification notification) {
+        startForeground(this.hashCode(), notification);
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        if (Objects.equals(
+                intent.getAction(), VmLauncherServices.ACTION_STOP_VM_LAUNCHER_SERVICE)) {
+            stopSelf();
+            return START_NOT_STICKY;
+        }
+        if (mVirtualMachine != null) {
+            Log.d(TAG, "VM instance is already started");
+            return START_NOT_STICKY;
+        }
+        mExecutorService = Executors.newCachedThreadPool();
+
+        ConfigJson json = ConfigJson.from(InstallUtils.getVmConfigPath(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 {
+            android.os.Trace.beginSection("vmCreate");
+            runner = Runner.create(this, config);
+            android.os.Trace.endSection();
+            android.os.Trace.beginAsyncSection("debianBoot", 0);
+        } catch (VirtualMachineException e) {
+            Log.e(TAG, "cannot create runner", e);
+            stopSelf();
+            return START_NOT_STICKY;
+        }
+        mVirtualMachine = runner.getVm();
+        mResultReceiver =
+                intent.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver.class);
+
+        runner.getExitStatus()
+                .thenAcceptAsync(
+                        success -> {
+                            if (mResultReceiver != null) {
+                                mResultReceiver.send(success ? RESULT_STOP : RESULT_ERROR, null);
+                            }
+                            stopSelf();
+                        });
+        Path logPath = getFileStreamPath(mVirtualMachine.getName() + ".log").toPath();
+        Logger.setup(mVirtualMachine, logPath, mExecutorService);
+
+        Notification notification =
+                intent.getParcelableExtra(EXTRA_NOTIFICATION, Notification.class);
+
+        startForeground(notification);
+
+        mResultReceiver.send(RESULT_START, null);
+
+        startDebianServer();
+
+        return START_NOT_STICKY;
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        stopDebianServer();
+        if (mVirtualMachine != null) {
+            if (mVirtualMachine.getStatus() == VirtualMachine.STATUS_RUNNING) {
+                try {
+                    mVirtualMachine.stop();
+                    stopForeground(STOP_FOREGROUND_REMOVE);
+                } catch (VirtualMachineException e) {
+                    Log.e(TAG, "failed to stop a VM instance", e);
+                }
+            }
+            mExecutorService.shutdownNow();
+            mExecutorService = null;
+            mVirtualMachine = null;
+        }
+    }
+
+    private void startDebianServer() {
+        ServerInterceptor interceptor =
+                new ServerInterceptor() {
+                    @Override
+                    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
+                            ServerCall<ReqT, RespT> call,
+                            Metadata headers,
+                            ServerCallHandler<ReqT, RespT> next) {
+                        // Refer to VirtualizationSystemService.TetheringService
+                        final String VM_STATIC_IP_ADDR = "192.168.0.2";
+                        InetSocketAddress remoteAddr =
+                                (InetSocketAddress)
+                                        call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR);
+
+                        if (remoteAddr != null
+                                && Objects.equals(
+                                        remoteAddr.getAddress().getHostAddress(),
+                                        VM_STATIC_IP_ADDR)) {
+                            // Allow the request only if it is from VM
+                            return next.startCall(call, headers);
+                        }
+                        Log.d(TAG, "blocked grpc request from " + remoteAddr);
+                        call.close(Status.Code.PERMISSION_DENIED.toStatus(), new Metadata());
+                        return new ServerCall.Listener<ReqT>() {};
+                    }
+                };
+        try {
+            // TODO(b/372666638): gRPC for java doesn't support vsock for now.
+            int port = 0;
+            mDebianService = new DebianServiceImpl(this, this);
+            mServer =
+                    OkHttpServerBuilder.forPort(port, InsecureServerCredentials.create())
+                            .intercept(interceptor)
+                            .addService(mDebianService)
+                            .build()
+                            .start();
+        } catch (IOException e) {
+            Log.d(TAG, "grpc server error", e);
+            return;
+        }
+
+        mExecutorService.execute(
+                () -> {
+                    // TODO(b/373533555): we can use mDNS for that.
+                    String debianServicePortFileName = "debian_service_port";
+                    File debianServicePortFile = new File(getFilesDir(), debianServicePortFileName);
+                    try (FileOutputStream writer = new FileOutputStream(debianServicePortFile)) {
+                        writer.write(String.valueOf(mServer.getPort()).getBytes());
+                    } catch (IOException e) {
+                        Log.d(TAG, "cannot write grpc port number", e);
+                    }
+                });
+    }
+
+    private void stopDebianServer() {
+        if (mDebianService != null) {
+            mDebianService.killForwarderHost();
+        }
+        if (mServer != null) {
+            mServer.shutdown();
+        }
+    }
+
+    @Override
+    public void onIpAddressAvailable(String ipAddr) {
+        android.os.Trace.endAsyncSection("debianBoot", 0);
+        Bundle b = new Bundle();
+        b.putString(VmLauncherService.KEY_VM_IP_ADDR, ipAddr);
+        mResultReceiver.send(VmLauncherService.RESULT_IPADDR, b);
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherServices.java b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherServices.java
new file mode 100644
index 0000000..d6c6786
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherServices.java
@@ -0,0 +1,122 @@
+/*
+ * 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.terminal;
+
+import android.app.Notification;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Parcel;
+import android.os.ResultReceiver;
+import android.util.Log;
+
+import java.util.List;
+
+public class VmLauncherServices {
+    private static final String TAG = "VmLauncherServices";
+
+    private static final String ACTION_START_VM_LAUNCHER_SERVICE =
+            "android.virtualization.START_VM_LAUNCHER_SERVICE";
+
+    public static final String ACTION_STOP_VM_LAUNCHER_SERVICE =
+            "android.virtualization.STOP_VM_LAUNCHER_SERVICE";
+    private static final int RESULT_START = 0;
+    private static final int RESULT_STOP = 1;
+    private static final int RESULT_ERROR = 2;
+    private static final int RESULT_IPADDR = 3;
+    private static final String KEY_VM_IP_ADDR = "ip_addr";
+
+    private static Intent buildVmLauncherServiceIntent(Context context) {
+        Intent i = new Intent();
+        i.setAction(ACTION_START_VM_LAUNCHER_SERVICE);
+
+        Intent intent = new Intent(ACTION_START_VM_LAUNCHER_SERVICE);
+        PackageManager pm = context.getPackageManager();
+        List<ResolveInfo> resolveInfos =
+                pm.queryIntentServices(intent, PackageManager.MATCH_DEFAULT_ONLY);
+        if (resolveInfos == null || resolveInfos.size() != 1) {
+            Log.e(TAG, "cannot find a service to handle ACTION_START_VM_LAUNCHER_SERVICE");
+            return null;
+        }
+        String packageName = resolveInfos.get(0).serviceInfo.packageName;
+
+        i.setPackage(packageName);
+        return i;
+    }
+
+    public static void stopVmLauncherService(Context context) {
+        Intent i = buildVmLauncherServiceIntent(context);
+        context.stopService(i);
+    }
+
+    public static void startVmLauncherService(
+            Context context, VmLauncherServiceCallback callback, Notification notification) {
+        Intent i = buildVmLauncherServiceIntent(context);
+        if (i == null) {
+            return;
+        }
+        ResultReceiver resultReceiver =
+                new ResultReceiver(new Handler(Looper.myLooper())) {
+                    @Override
+                    protected void onReceiveResult(int resultCode, Bundle resultData) {
+                        if (callback == null) {
+                            return;
+                        }
+                        switch (resultCode) {
+                            case RESULT_START:
+                                callback.onVmStart();
+                                return;
+                            case RESULT_STOP:
+                                callback.onVmStop();
+                                return;
+                            case RESULT_ERROR:
+                                callback.onVmError();
+                                return;
+                            case RESULT_IPADDR:
+                                callback.onIpAddrAvailable(resultData.getString(KEY_VM_IP_ADDR));
+                                return;
+                        }
+                    }
+                };
+        i.putExtra(Intent.EXTRA_RESULT_RECEIVER, getResultReceiverForIntent(resultReceiver));
+        i.putExtra(VmLauncherService.EXTRA_NOTIFICATION, notification);
+        context.startForegroundService(i);
+    }
+
+    public interface VmLauncherServiceCallback {
+        void onVmStart();
+
+        void onVmStop();
+
+        void onVmError();
+
+        void onIpAddrAvailable(String ipAddr);
+    }
+
+    private static ResultReceiver getResultReceiverForIntent(ResultReceiver r) {
+        Parcel parcel = Parcel.obtain();
+        r.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        r = ResultReceiver.CREATOR.createFromParcel(parcel);
+        parcel.recycle();
+        return r;
+    }
+}
diff --git a/android/TerminalApp/proguard.flags b/android/TerminalApp/proguard.flags
index 8433e82..88b8a9c 100644
--- a/android/TerminalApp/proguard.flags
+++ b/android/TerminalApp/proguard.flags
@@ -11,8 +11,8 @@
 #-keep class com.google.gson.stream.** { *; }
 
 # Application classes that will be serialized/deserialized over Gson
--keep class com.android.virtualization.vmlauncher.ConfigJson { <fields>; }
--keep class com.android.virtualization.vmlauncher.ConfigJson$* { <fields>; }
+-keep class com.android.virtualization.terminal.ConfigJson { <fields>; }
+-keep class com.android.virtualization.terminal.ConfigJson$* { <fields>; }
 
 # Prevent proguard from stripping interface information from TypeAdapter, TypeAdapterFactory,
 # JsonSerializer, JsonDeserializer instances (so they can be used in @JsonAdapter)
diff --git a/android/forwarder_host/src/forwarder_host.rs b/android/forwarder_host/src/forwarder_host.rs
index 7496a02..2138957 100644
--- a/android/forwarder_host/src/forwarder_host.rs
+++ b/android/forwarder_host/src/forwarder_host.rs
@@ -378,7 +378,7 @@
 
 /// JNI function for running forwarder_host.
 #[no_mangle]
-pub extern "C" fn Java_com_android_virtualization_vmlauncher_DebianServiceImpl_runForwarderHost(
+pub extern "C" fn Java_com_android_virtualization_terminal_DebianServiceImpl_runForwarderHost(
     env: JNIEnv,
     _class: JObject,
     cid: jint,
@@ -396,7 +396,7 @@
 
 /// JNI function for terminating forwarder_host.
 #[no_mangle]
-pub extern "C" fn Java_com_android_virtualization_vmlauncher_DebianServiceImpl_terminateForwarderHost(
+pub extern "C" fn Java_com_android_virtualization_terminal_DebianServiceImpl_terminateForwarderHost(
     _env: JNIEnv,
     _class: JObject,
 ) {
@@ -405,7 +405,7 @@
 
 /// JNI function for updating listening ports.
 #[no_mangle]
-pub extern "C" fn Java_com_android_virtualization_vmlauncher_DebianServiceImpl_updateListeningPorts(
+pub extern "C" fn Java_com_android_virtualization_terminal_DebianServiceImpl_updateListeningPorts(
     env: JNIEnv,
     _class: JObject,
     ports: JIntArray,