Merge changes I74c62e77,I18af2ded,Ib9eabfbe into main

* changes:
  VmTerminalApp: Initial skeleton for downloading from server
  VmTerminalApp: Add UI skeleton for download activity and service
  vm_launcher_lib: Fix bug in InstallUtils#isImageInstalled()
diff --git a/android/TerminalApp/Android.bp b/android/TerminalApp/Android.bp
index 932ca76..5e45c02 100644
--- a/android/TerminalApp/Android.bp
+++ b/android/TerminalApp/Android.bp
@@ -11,6 +11,7 @@
     resource_dirs: ["res"],
     asset_dirs: ["assets"],
     static_libs: [
+        "VmTerminalApp.aidl-java",
         "vm_launcher_lib",
         "androidx-constraintlayout_constraintlayout",
         "com.google.android.material_material",
@@ -26,3 +27,18 @@
         "com.android.virt",
     ],
 }
+
+aidl_interface {
+    name: "VmTerminalApp.aidl",
+    srcs: ["aidl/**/*.aidl"],
+    unstable: true,
+    local_include_dir: "aidl",
+    backend: {
+        java: {
+            enabled: true,
+            apex_available: [
+                "com.android.virt",
+            ],
+        },
+    },
+}
diff --git a/android/TerminalApp/AndroidManifest.xml b/android/TerminalApp/AndroidManifest.xml
index 28b5436..61737fe 100644
--- a/android/TerminalApp/AndroidManifest.xml
+++ b/android/TerminalApp/AndroidManifest.xml
@@ -3,6 +3,7 @@
     xmlns:tools="http://schemas.android.com/tools"
     package="com.android.virtualization.terminal">
 
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
     <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
     <uses-permission android:name="android.permission.MANAGE_VIRTUAL_MACHINE" />
     <uses-permission android:name="android.permission.USE_CUSTOM_VIRTUAL_MACHINE" />
@@ -55,6 +56,11 @@
                 <action android:name="android.intent.action.MAIN" />
             </intent-filter>
         </activity>
+        <service android:name=".InstallerService"
+            android:foregroundServiceType="specialUse"
+            android:value="Prepares Linux image"
+            android:exported="false"
+            android:stopWithTask="true" />
 
         <service
             android:name="com.android.virtualization.vmlauncher.VmLauncherService"
diff --git a/android/TerminalApp/aidl/com/android/virtualization/terminal/IInstallProgressListener.aidl b/android/TerminalApp/aidl/com/android/virtualization/terminal/IInstallProgressListener.aidl
new file mode 100644
index 0000000..94e33d9
--- /dev/null
+++ b/android/TerminalApp/aidl/com/android/virtualization/terminal/IInstallProgressListener.aidl
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.virtualization.terminal;
+
+// TODO(b/374015561): Provide progress update
+oneway interface IInstallProgressListener {
+    void onCompleted();
+    void onError(in String displayText);
+}
diff --git a/android/TerminalApp/aidl/com/android/virtualization/terminal/IInstallerService.aidl b/android/TerminalApp/aidl/com/android/virtualization/terminal/IInstallerService.aidl
new file mode 100644
index 0000000..daf1fa4
--- /dev/null
+++ b/android/TerminalApp/aidl/com/android/virtualization/terminal/IInstallerService.aidl
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.virtualization.terminal;
+
+import com.android.virtualization.terminal.IInstallProgressListener;
+
+interface IInstallerService {
+    void requestInstall();
+    void setProgressListener(in IInstallProgressListener listener);
+
+    boolean isInstalling();
+    boolean isInstalled();
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.java
new file mode 100644
index 0000000..66552d5
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.virtualization.terminal;
+
+import android.Manifest;
+import android.content.pm.PackageManager;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+public abstract class BaseActivity extends AppCompatActivity {
+    private static final int POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE = 101;
+
+    @Override
+    public void onResume() {
+        super.onResume();
+
+        if (getApplicationContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
+                != PackageManager.PERMISSION_GRANTED) {
+            requestPermissions(
+                    new String[] {Manifest.permission.POST_NOTIFICATIONS},
+                    POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE);
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
index a49ea72..428fd91 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
@@ -16,56 +16,235 @@
 
 package com.android.virtualization.terminal;
 
-import android.app.Activity;
+import android.annotation.MainThread;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
 import android.os.Build;
 import android.os.Bundle;
+import android.os.FileUtils;
+import android.os.IBinder;
+import android.os.RemoteException;
+import android.text.format.Formatter;
 import android.util.Log;
+import android.widget.CheckBox;
 import android.widget.TextView;
+import android.widget.Toast;
 
-import com.android.virtualization.vmlauncher.InstallUtils;
-
+import java.lang.ref.WeakReference;
 import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
 
-public class InstallerActivity extends Activity {
+public class InstallerActivity extends BaseActivity {
     private static final String TAG = "LinuxInstaller";
 
-    ExecutorService executorService = Executors.newSingleThreadExecutor();
+    private static final long ESTIMATED_IMG_SIZE_BYTES = FileUtils.parseSize("350MB");
+
+    private ExecutorService mExecutorService;
+    private CheckBox mWaitForWifiCheckbox;
+    private TextView mInstallButton;
+
+    private IInstallerService mService;
+    private ServiceConnection mInstallerServiceConnection;
+    private InstallProgressListener mInstallProgressListener;
+    private boolean mInstallRequested;
 
     @Override
-    protected void onCreate(Bundle savedInstanceState) {
+    public void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         setResult(RESULT_CANCELED);
 
+        mInstallProgressListener = new InstallProgressListener(this);
+
         setContentView(R.layout.activity_installer);
 
-        executorService.execute(this::installLinuxImage);
+        TextView desc = (TextView) findViewById(R.id.installer_desc);
+        desc.setText(
+                getString(
+                        R.string.installer_desc_text_format,
+                        Formatter.formatShortFileSize(this, ESTIMATED_IMG_SIZE_BYTES)));
+
+        mWaitForWifiCheckbox = (CheckBox) findViewById(R.id.installer_wait_for_wifi_checkbox);
+        mInstallButton = (TextView) findViewById(R.id.installer_install_button);
+
+        mInstallButton.setOnClickListener(
+                (event) -> {
+                    requestInstall();
+                });
+
+        Intent intent = new Intent(this, InstallerService.class);
+        mInstallerServiceConnection = new InstallerServiceConnection(this);
+        if (!bindService(intent, mInstallerServiceConnection, Context.BIND_AUTO_CREATE)) {
+            handleCriticalError(new Exception("Failed to connect to installer service"));
+        }
     }
 
-    private void installLinuxImage() {
-        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");
+    @Override
+    public void onDestroy() {
+        if (mInstallerServiceConnection != null) {
+            unbindService(mInstallerServiceConnection);
+            mInstallerServiceConnection = null;
         }
-        setResult(RESULT_CANCELED, null);
+
+        super.onDestroy();
+    }
+
+    public void handleCriticalError(Exception e) {
+        if (Build.isDebuggable()) {
+            Toast.makeText(
+                            this,
+                            e.getMessage() + ". File a bugreport to go/ferrochrome-bug",
+                            Toast.LENGTH_LONG)
+                    .show();
+        }
+        Log.e(TAG, "Internal error", e);
+        finishWithResult(RESULT_CANCELED);
+    }
+
+    private void finishWithResult(int resultCode) {
+        setResult(resultCode);
         finish();
     }
 
-    private void updateStatus(String line) {
-        runOnUiThread(
-                () -> {
-                    TextView statusView = findViewById(R.id.status_txt_view);
-                    statusView.append(line + "\n");
-                });
+    private void setInstallEnabled(boolean enable) {
+        mInstallButton.setEnabled(enable);
+        mWaitForWifiCheckbox.setEnabled(enable);
+
+        int resId =
+                enable
+                        ? R.string.installer_install_button_enabled_text
+                        : R.string.installer_install_button_disabled_text;
+        mInstallButton.setText(getString(resId));
+    }
+
+    @MainThread
+    private void requestInstall() {
+        setInstallEnabled(/* enable= */ false);
+
+        if (mService != null) {
+            try {
+                mService.requestInstall();
+            } catch (RemoteException e) {
+                handleCriticalError(e);
+            }
+        } else {
+            Log.d(TAG, "requestInstall() is called, but not yet connected");
+            mInstallRequested = true;
+        }
+    }
+
+    @MainThread
+    public void handleInstallerServiceConnected() {
+        try {
+            mService.setProgressListener(mInstallProgressListener);
+            if (mService.isInstalled()) {
+                // Finishing this activity will trigger MainActivity::onResume(),
+                // and VM will be started from there.
+                finishWithResult(RESULT_OK);
+                return;
+            }
+
+            if (mInstallRequested) {
+                requestInstall();
+            } else if (mService.isInstalling()) {
+                setInstallEnabled(false);
+            }
+        } catch (RemoteException e) {
+            handleCriticalError(e);
+        }
+    }
+
+    @MainThread
+    public void handleInstallerServiceDisconnected() {
+        handleCriticalError(new Exception("InstallerService is destroyed while in use"));
+    }
+
+    @MainThread
+    private void handleError(String displayText) {
+        // TODO(b/375542145): Display error with snackbar.
+        if (Build.isDebuggable()) {
+            Toast.makeText(this, displayText, Toast.LENGTH_LONG).show();
+        }
+        setInstallEnabled(true);
+    }
+
+    private static class InstallProgressListener extends IInstallProgressListener.Stub {
+        private final WeakReference<InstallerActivity> mActivity;
+
+        InstallProgressListener(InstallerActivity activity) {
+            mActivity = new WeakReference<>(activity);
+        }
+
+        @Override
+        public void onCompleted() {
+            InstallerActivity activity = mActivity.get();
+            if (activity == null) {
+                // Ignore incoming connection or disconnection after activity is destroyed.
+                return;
+            }
+
+            // MainActivity will be resume and handle rest of progress.
+            activity.finishWithResult(RESULT_OK);
+        }
+
+        @Override
+        public void onError(String displayText) {
+            InstallerActivity context = mActivity.get();
+            if (context == null) {
+                // Ignore incoming connection or disconnection after activity is destroyed.
+                return;
+            }
+
+            context.runOnUiThread(
+                    () -> {
+                        InstallerActivity activity = mActivity.get();
+                        if (activity == null) {
+                            // Ignore incoming connection or disconnection after activity is
+                            // destroyed.
+                            return;
+                        }
+
+                        activity.handleError(displayText);
+                    });
+        }
+    }
+
+    @MainThread
+    public static final class InstallerServiceConnection implements ServiceConnection {
+        private final WeakReference<InstallerActivity> mActivity;
+
+        InstallerServiceConnection(InstallerActivity activity) {
+            mActivity = new WeakReference<>(activity);
+        }
+
+        @Override
+        public void onServiceConnected(ComponentName name, IBinder service) {
+            InstallerActivity activity = mActivity.get();
+            if (activity == null || activity.mInstallerServiceConnection == null) {
+                // Ignore incoming connection or disconnection after activity is destroyed.
+                return;
+            }
+            if (service == null) {
+                activity.handleCriticalError(new Exception("service shouldn't be null"));
+            }
+
+            activity.mService = IInstallerService.Stub.asInterface(service);
+            activity.handleInstallerServiceConnected();
+        }
+
+        @Override
+        public void onServiceDisconnected(ComponentName name) {
+            InstallerActivity activity = mActivity.get();
+            if (activity == null || activity.mInstallerServiceConnection == null) {
+                // Ignore incoming connection or disconnection after activity is destroyed.
+                return;
+            }
+
+            if (activity.mInstallerServiceConnection != null) {
+                activity.unbindService(activity.mInstallerServiceConnection);
+                activity.mInstallerServiceConnection = null;
+            }
+            activity.handleInstallerServiceDisconnected();
+        }
     }
 }
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
new file mode 100644
index 0000000..b3102db
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
@@ -0,0 +1,287 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.virtualization.terminal;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.app.Service;
+import android.content.Intent;
+import android.content.pm.ServiceInfo;
+import android.os.Build;
+import android.os.IBinder;
+import android.util.Log;
+
+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;
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.lang.ref.WeakReference;
+import java.net.URL;
+import java.net.UnknownHostException;
+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 InstallerService extends Service {
+    private static final String TAG = "InstallerService";
+
+    private static final String NOTIFICATION_CHANNEL_ID = "installer";
+    private static final int NOTIFICATION_ID = 1313; // any unique number among notifications
+
+    // TODO(b/369740847): Replace this URL with dl.google.com
+    private static final String IMAGE_URL =
+            "https://github.com/ikicha/debian_ci/releases/download/first/images.tar.gz";
+
+    private final Object mLock = new Object();
+
+    private Notification mNotification;
+
+    @GuardedBy("mLock")
+    private boolean mIsInstalling;
+
+    @GuardedBy("mLock")
+    private IInstallProgressListener mListener;
+
+    private ExecutorService mExecutorService;
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+
+        // Create mandatory notification
+        NotificationManager manager = getSystemService(NotificationManager.class);
+        if (manager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) == null) {
+            NotificationChannel channel =
+                    new NotificationChannel(
+                            NOTIFICATION_CHANNEL_ID,
+                            getString(R.string.installer_notif_title_text),
+                            NotificationManager.IMPORTANCE_DEFAULT);
+            manager.createNotificationChannel(channel);
+        }
+
+        Intent intent = new Intent(this, MainActivity.class);
+        PendingIntent pendingIntent =
+                PendingIntent.getActivity(
+                        this, /* requestCode= */ 0, intent, PendingIntent.FLAG_IMMUTABLE);
+        mNotification =
+                new Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
+                        .setSmallIcon(R.drawable.ic_launcher_foreground)
+                        .setContentTitle(getString(R.string.installer_notif_title_text))
+                        .setContentText(getString(R.string.installer_notif_desc_text))
+                        .setOngoing(true)
+                        .setContentIntent(pendingIntent)
+                        .build();
+
+        mExecutorService = Executors.newSingleThreadExecutor();
+    }
+
+    @Nullable
+    @Override
+    public IBinder onBind(Intent intent) {
+        return new InstallerServiceImpl(this);
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        super.onStartCommand(intent, flags, startId);
+
+        Log.d(TAG, "Starting service ...");
+
+        return START_STICKY;
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+
+        Log.d(TAG, "Service is destroyed");
+        if (mExecutorService != null) {
+            mExecutorService.shutdown();
+        }
+    }
+
+    private void requestInstall() {
+        Log.i(TAG, "Installing..");
+
+        // Make service to be long running, even after unbind() when InstallerActivity is destroyed
+        // The service will still be destroyed if task is remove.
+        startService(new Intent(this, InstallerService.class));
+        startForeground(
+                NOTIFICATION_ID, mNotification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE);
+        synchronized (mLock) {
+            mIsInstalling = true;
+        }
+
+        mExecutorService.execute(
+                () -> {
+                    // TODO(b/374015561): Provide progress update
+                    boolean success = downloadFromSdcard() || downloadFromUrl();
+
+                    stopForeground(STOP_FOREGROUND_REMOVE);
+
+                    synchronized (mLock) {
+                        mIsInstalling = false;
+                    }
+                    if (success) {
+                        notifyCompleted();
+                    }
+                });
+    }
+
+    private boolean downloadFromSdcard() {
+        // Installing from sdcard is preferred, but only supported only in debuggable build.
+        if (Build.isDebuggable()) {
+            Log.i(TAG, "trying to install /sdcard/linux/images.tar.gz");
+
+            if (InstallUtils.installImageFromExternalStorage(this)) {
+                Log.i(TAG, "image is installed from /sdcard/linux/images.tar.gz");
+                return true;
+            }
+            Log.i(TAG, "Failed to install /sdcard/linux/images.tar.gz");
+        } else {
+            Log.i(TAG, "Non-debuggable build doesn't support installation from /sdcard/linux");
+        }
+        return false;
+    }
+
+    // TODO(b/374015561): Support pause/resume download
+    // TODO(b/374015561): Wait for Wi-Fi on metered network if requested.
+    private boolean downloadFromUrl() {
+        Log.i(TAG, "trying to download from " + IMAGE_URL);
+
+        try (BufferedInputStream inputStream =
+                        new BufferedInputStream(new URL(IMAGE_URL).openStream());
+                TarArchiveInputStream tar =
+                        new TarArchiveInputStream(new GzipCompressorInputStream(inputStream))) {
+            ArchiveEntry entry;
+            Path baseDir = InstallUtils.getInternalStorageDir(this).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 (UnknownHostException e) {
+            // Log.e() doesn't print stack trace for UnknownHostException
+            Log.e(TAG, "Install failed UnknownHostException: " + e.getMessage());
+            notifyError(getString(R.string.installer_install_network_error_message));
+            return false;
+        } catch (IOException e) {
+            // TODO(b/374015561): Provide more finer grained error message
+            Log.e(TAG, "Installation failed", e);
+            notifyError(getString(R.string.installer_error_unknown));
+            return false;
+        }
+
+        if (!InstallUtils.resolvePathInVmConfig(this)) {
+            // TODO(b/374015561): Provide more finer grained error message
+            notifyError(getString(R.string.installer_error_unknown));
+            return false;
+        }
+        return true;
+    }
+
+    private void notifyError(String displayText) {
+        IInstallProgressListener listener;
+        synchronized (mLock) {
+            listener = mListener;
+        }
+
+        try {
+            listener.onError(displayText);
+        } catch (Exception e) {
+            // ignore. Activity may not exist.
+        }
+    }
+
+    private void notifyCompleted() {
+        IInstallProgressListener listener;
+        synchronized (mLock) {
+            listener = mListener;
+        }
+
+        try {
+            listener.onCompleted();
+        } catch (Exception e) {
+            // ignore. Activity may not exist.
+        }
+    }
+
+    private static final class InstallerServiceImpl extends IInstallerService.Stub {
+        // Holds weak reference to avoid Context leak
+        private final WeakReference<InstallerService> mService;
+
+        public InstallerServiceImpl(InstallerService service) {
+            mService = new WeakReference<>(service);
+        }
+
+        private InstallerService ensureServiceConnected() throws RuntimeException {
+            InstallerService service = mService.get();
+            if (service == null) {
+                throw new RuntimeException(
+                        "Internal error: Installer service is being accessed after destroyed");
+            }
+            return service;
+        }
+
+        @Override
+        public void requestInstall() {
+            InstallerService service = ensureServiceConnected();
+            synchronized (service.mLock) {
+                service.requestInstall();
+            }
+        }
+
+        @Override
+        public void setProgressListener(IInstallProgressListener listener) {
+            InstallerService service = ensureServiceConnected();
+            synchronized (service.mLock) {
+                service.mListener = listener;
+            }
+        }
+
+        @Override
+        public boolean isInstalling() {
+            InstallerService service = ensureServiceConnected();
+            synchronized (service.mLock) {
+                return service.mIsInstalling;
+            }
+        }
+
+        @Override
+        public boolean isInstalled() {
+            InstallerService service = ensureServiceConnected();
+            synchronized (service.mLock) {
+                return !service.mIsInstalling && InstallUtils.isImageInstalled(service);
+            }
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
index 0a750e3..437e0be 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
@@ -15,19 +15,16 @@
  */
 package com.android.virtualization.terminal;
 
-import android.Manifest;
 import android.app.Notification;
 import android.app.NotificationChannel;
 import android.app.NotificationManager;
 import android.app.PendingIntent;
 import android.content.Context;
 import android.content.Intent;
-import android.content.pm.PackageManager;
 import android.content.res.Configuration;
 import android.graphics.drawable.Icon;
 import android.graphics.fonts.FontStyle;
 import android.net.http.SslError;
-import android.os.Build;
 import android.os.Bundle;
 import android.system.ErrnoException;
 import android.system.Os;
@@ -45,8 +42,6 @@
 import android.webkit.WebViewClient;
 import android.widget.Toast;
 
-import androidx.appcompat.app.AppCompatActivity;
-
 import com.android.virtualization.vmlauncher.InstallUtils;
 import com.android.virtualization.vmlauncher.VmLauncherServices;
 
@@ -68,7 +63,7 @@
 import java.security.cert.Certificate;
 import java.security.cert.X509Certificate;
 
-public class MainActivity extends AppCompatActivity
+public class MainActivity extends BaseActivity
         implements VmLauncherServices.VmLauncherServiceCallback,
                 AccessibilityManager.TouchExplorationStateChangeListener {
 
@@ -99,13 +94,12 @@
                     .show();
         }
 
-        checkAndRequestPostNotificationsPermission();
-
         NotificationManager notificationManager = getSystemService(NotificationManager.class);
-        NotificationChannel notificationChannel =
-                new NotificationChannel(TAG, TAG, NotificationManager.IMPORTANCE_LOW);
-        assert notificationManager != null;
-        notificationManager.createNotificationChannel(notificationChannel);
+        if (notificationManager.getNotificationChannel(TAG) == null) {
+            NotificationChannel notificationChannel =
+                    new NotificationChannel(TAG, TAG, NotificationManager.IMPORTANCE_LOW);
+            notificationManager.createNotificationChannel(notificationChannel);
+        }
 
         setContentView(R.layout.activity_headless);
 
@@ -338,15 +332,6 @@
         return;
     }
 
-    private void checkAndRequestPostNotificationsPermission() {
-        if (getApplicationContext().checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
-                != PackageManager.PERMISSION_GRANTED) {
-            requestPermissions(
-                    new String[]{Manifest.permission.POST_NOTIFICATIONS},
-                    POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE);
-        }
-    }
-
     @Override
     protected void onDestroy() {
         getSystemService(AccessibilityManager.class).removeTouchExplorationStateChangeListener(this);
@@ -416,8 +401,7 @@
     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)) {
+        if (!InstallUtils.isImageInstalled(this)) {
             Intent intent = new Intent(this, InstallerActivity.class);
             startActivityForResult(intent, REQUEST_CODE_INSTALLER);
             return true;
diff --git a/android/TerminalApp/res/drawable/ic_lock_open.xml b/android/TerminalApp/res/drawable/ic_lock_open.xml
new file mode 100644
index 0000000..c623592
--- /dev/null
+++ b/android/TerminalApp/res/drawable/ic_lock_open.xml
@@ -0,0 +1,11 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="48dp"
+    android:height="48dp"
+    android:viewportWidth="960"
+    android:viewportHeight="960"
+    android:tint="?attr/colorControlNormal">
+  <path
+      android:fillColor="@android:color/white"
+      android:pathData="M220,326L610,326L610,230Q610,175.83 572.12,137.92Q534.24,100 480.12,100Q426,100 388,137.92Q350,175.83 350,230L290,230Q290,151 345.61,95.5Q401.21,40 480.11,40Q559,40 614.5,95.58Q670,151.15 670,230L670,326L740,326Q764.75,326 782.38,343.62Q800,361.25 800,386L800,820Q800,844.75 782.38,862.37Q764.75,880 740,880L220,880Q195.25,880 177.63,862.37Q160,844.75 160,820L160,386Q160,361.25 177.63,343.62Q195.25,326 220,326ZM220,820L740,820Q740,820 740,820Q740,820 740,820L740,386Q740,386 740,386Q740,386 740,386L220,386Q220,386 220,386Q220,386 220,386L220,820Q220,820 220,820Q220,820 220,820ZM480.17,680Q512,680 534.5,657.97Q557,635.94 557,605Q557,575 534.33,550.5Q511.66,526 479.83,526Q448,526 425.5,550.5Q403,575 403,605.5Q403,636 425.67,658Q448.34,680 480.17,680ZM220,820Q220,820 220,820Q220,820 220,820L220,386Q220,386 220,386Q220,386 220,386L220,386Q220,386 220,386Q220,386 220,386L220,820Q220,820 220,820Q220,820 220,820Z"/>
+</vector>
+
diff --git a/android/TerminalApp/res/layout/activity_installer.xml b/android/TerminalApp/res/layout/activity_installer.xml
index 3967167..c375cb8 100644
--- a/android/TerminalApp/res/layout/activity_installer.xml
+++ b/android/TerminalApp/res/layout/activity_installer.xml
@@ -6,10 +6,51 @@
     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"/>
+    <TextView
+        android:id="@+id/installer_title"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_alignParentTop="true"
+        android:layout_alignParentStart="true"
+        android:textSize="32sp"
+        android:text="@string/installer_title_text" />
+
+    <ImageView
+        android:id="@+id/installer_icon"
+        android:layout_width="match_parent"
+        android:layout_height="300dp"
+        android:padding="10dp"
+        android:layout_below="@id/installer_title"
+        android:layout_alignParentStart="true"
+        android:src="@drawable/ic_lock_open"
+        android:adjustViewBounds="true" />
+
+    <TextView
+        android:id="@+id/installer_desc"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:padding="10dp"
+        android:layout_below="@id/installer_icon"
+        android:layout_alignParentStart="true"
+        android:singleLine="false"
+        android:textSize="24sp" />
+
+    <CheckBox
+        android:id="@+id/installer_wait_for_wifi_checkbox"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="10dp"
+        android:layout_alignParentEnd="true"
+        android:layout_below="@id/installer_desc"
+        android:text="@string/installer_wait_for_wifi_checkbox_text" />
+
+    <Button
+        android:id="@+id/installer_install_button"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:padding="10dp"
+        android:layout_alignParentBottom="true"
+        android:layout_alignParentEnd="true"
+        android:text="@string/installer_install_button_enabled_text" />
 
 </RelativeLayout>
diff --git a/android/TerminalApp/res/values/strings.xml b/android/TerminalApp/res/values/strings.xml
index 0cdb939..f8350a0 100644
--- a/android/TerminalApp/res/values/strings.xml
+++ b/android/TerminalApp/res/values/strings.xml
@@ -20,6 +20,27 @@
     <!-- Application name of this terminal app shown in the launcher. This app provides computer terminal to connect to virtual machine. [CHAR LIMIT=16] -->
     <string name="app_name">Terminal</string>
 
+    <!-- Installer activity title [CHAR LIMIT=none] -->
+    <string name="installer_title_text">Install Linux terminal</string>
+    <!-- Installer activity description format [CHAR LIMIT=none] -->
+    <string name="installer_desc_text_format">To launch Linux terminal, you need to download roughly <xliff:g id="expected_size" example="350GB">%1$s</xliff:g> of data over network.\nWould you proceed?</string>
+    <!-- Checkbox at the installer activity to wait for Wi-Fi on metered network to prevent from paying network traffic [CHAR LIMIT=none] -->
+    <string name="installer_wait_for_wifi_checkbox_text">Wait for Wi-Fi on metered network</string>
+    <!-- Button at the installer activity to confirm installation [CHAR LIMIT=16] -->
+    <string name="installer_install_button_enabled_text">Install</string>
+    <!-- Button at the installer activity to when installation is already in progress [CHAR LIMIT=16] -->
+    <string name="installer_install_button_disabled_text">Installing</string>
+    <!-- Toast message at installer activity when network doesn't meet[CHAR LIMIT=none] -->
+    <string name="installer_install_network_error_message">Network error. Check connection and retry.</string>
+    <!-- Notification title for installer [CHAR LIMIT=64] -->
+    <string name="installer_notif_title_text">Installing Linux terminal</string>
+    <!-- Notification description for installer [CHAR LIMIT=none] -->
+    <string name="installer_notif_desc_text">Linux terminal will be started after finish</string>
+    <!-- Toast error message for install failure due to the network issue [CHAR LIMIT=none] -->
+    <string name="installer_error_network">Failed to install due to the network issue</string>
+    <!-- Toast error message for install failure due to the unidentified issue [CHAR LIMIT=none] -->
+    <string name="installer_error_unknown">Failed to install. Try again.</string>
+
     <!-- Action bar icon name for the settings view CHAR LIMIT=none] -->
     <string name="action_settings">Settings</string>
 
diff --git a/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/InstallUtils.java b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/InstallUtils.java
index eb6dd77..17dc8dd 100644
--- a/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/InstallUtils.java
+++ b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/InstallUtils.java
@@ -38,17 +38,15 @@
 
     private static final String VM_CONFIG_FILENAME = "vm_config.json";
     private static final String COMPRESSED_PAYLOAD_FILENAME = "images.tar.gz";
+    private static final String INSTALLATION_COMPLETED_FILENAME = "completed";
     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();
+        return getInternalStorageDir(context).toPath().resolve(VM_CONFIG_FILENAME).toString();
     }
 
     public static boolean isImageInstalled(Context context) {
-        return Files.exists(Path.of(getVmConfigPath(context)));
+        return Files.exists(getInstallationCompletedPath(context));
     }
 
     private static Path getPayloadPath() {
@@ -65,9 +63,18 @@
         return Files.exists(getPayloadPath());
     }
 
+    public static File getInternalStorageDir(Context context) {
+        return new File(context.getFilesDir(), PAYLOAD_DIR);
+    }
+
+    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 =
@@ -89,10 +96,6 @@
             Log.e(TAG, "installation failed", e);
             return false;
         }
-        if (!isImageInstalled(context)) {
-            return false;
-        }
-
         if (!resolvePathInVmConfig(context)) {
             Log.d(TAG, "resolving path failed");
             try {
@@ -110,6 +113,14 @@
             Log.d(TAG, "failed to remove installed payload", e);
         }
 
+        // Create marker for installation done.
+        try {
+            File file = new File(getInstallationCompletedPath(context).toString());
+            file.createNewFile();
+        } catch (IOException e) {
+            Log.e(TAG, "Failed to mark install completed", e);
+            return false;
+        }
         return true;
     }
 
@@ -124,7 +135,7 @@
         };
     }
 
-    private static boolean resolvePathInVmConfig(Context context) {
+    public static boolean resolvePathInVmConfig(Context context) {
         try {
             String replacedVmConfig =
                     String.join(
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 849cc24..3731854 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
@@ -62,8 +62,8 @@
 
     @Override
     public int onStartCommand(Intent intent, int flags, int startId) {
-        if (isVmRunning()) {
-            Log.d(TAG, "there is already the running VM instance");
+        if (mVirtualMachine != null) {
+            Log.d(TAG, "VM instance is already started");
             return START_NOT_STICKY;
         }
         mExecutorService = Executors.newCachedThreadPool();
@@ -114,12 +114,14 @@
     @Override
     public void onDestroy() {
         super.onDestroy();
-        if (isVmRunning()) {
-            try {
-                mVirtualMachine.stop();
-                stopForeground(STOP_FOREGROUND_REMOVE);
-            } catch (VirtualMachineException e) {
-                Log.e(TAG, "failed to stop a VM instance", e);
+        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;
@@ -128,11 +130,6 @@
         stopDebianServer();
     }
 
-    private boolean isVmRunning() {
-        return mVirtualMachine != null
-                && mVirtualMachine.getStatus() == VirtualMachine.STATUS_RUNNING;
-    }
-
     private void startDebianServer() {
         new Thread(
                         () -> {