VmTerminalApp: Initial skeleton for downloading from server

Bug: 369740847
Test: Manually
Change-Id: I74c62e77a66eed871729c0f9def044dfb92a479c
diff --git a/android/TerminalApp/AndroidManifest.xml b/android/TerminalApp/AndroidManifest.xml
index 67ef199..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" />
diff --git a/android/TerminalApp/aidl/com/android/virtualization/terminal/IInstallProgressListener.aidl b/android/TerminalApp/aidl/com/android/virtualization/terminal/IInstallProgressListener.aidl
index 1f51723..94e33d9 100644
--- a/android/TerminalApp/aidl/com/android/virtualization/terminal/IInstallProgressListener.aidl
+++ b/android/TerminalApp/aidl/com/android/virtualization/terminal/IInstallProgressListener.aidl
@@ -19,4 +19,5 @@
 // TODO(b/374015561): Provide progress update
 oneway interface IInstallProgressListener {
     void onCompleted();
+    void onError(in String displayText);
 }
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
index 25780a5..428fd91 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
@@ -106,15 +106,20 @@
         finish();
     }
 
-    private void preventInstall() {
-        mWaitForWifiCheckbox.setEnabled(false);
-        mInstallButton.setEnabled(false);
-        mInstallButton.setText(getString(R.string.installer_install_button_disabled_text));
+    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() {
-        preventInstall();
+        setInstallEnabled(/* enable= */ false);
 
         if (mService != null) {
             try {
@@ -123,6 +128,7 @@
                 handleCriticalError(e);
             }
         } else {
+            Log.d(TAG, "requestInstall() is called, but not yet connected");
             mInstallRequested = true;
         }
     }
@@ -141,7 +147,7 @@
             if (mInstallRequested) {
                 requestInstall();
             } else if (mService.isInstalling()) {
-                preventInstall();
+                setInstallEnabled(false);
             }
         } catch (RemoteException e) {
             handleCriticalError(e);
@@ -153,6 +159,15 @@
         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;
 
@@ -171,6 +186,27 @@
             // 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
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
index f2c2867..b3102db 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
@@ -32,7 +32,18 @@
 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;
 
@@ -42,6 +53,10 @@
     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;
@@ -124,30 +139,89 @@
 
         mExecutorService.execute(
                 () -> {
-                    Log.d(TAG, "installLinuxImage");
+                    // TODO(b/374015561): Provide progress update
+                    boolean success = downloadFromSdcard() || downloadFromUrl();
 
-                    // 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");
-
-                        // TODO(b/374015561): Provide progress update
-                        if (InstallUtils.installImageFromExternalStorage(this)) {
-                            Log.i(TAG, "image is installed from /sdcard/linux/images.tar.gz");
-                        } else {
-                            // TODO(b/374015561): Notify error
-                            Log.e(TAG, "Install failed");
-                        }
-                    }
                     stopForeground(STOP_FOREGROUND_REMOVE);
 
                     synchronized (mLock) {
                         mIsInstalling = false;
                     }
-                    notifyCompleted();
+                    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) {
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
index 206f5da..437e0be 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
@@ -25,7 +25,6 @@
 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;
@@ -402,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/values/strings.xml b/android/TerminalApp/res/values/strings.xml
index 7cacd3b..f8350a0 100644
--- a/android/TerminalApp/res/values/strings.xml
+++ b/android/TerminalApp/res/values/strings.xml
@@ -36,6 +36,10 @@
     <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 e9edbc8..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
@@ -63,7 +63,7 @@
         return Files.exists(getPayloadPath());
     }
 
-    private static File getInternalStorageDir(Context context) {
+    public static File getInternalStorageDir(Context context) {
         return new File(context.getFilesDir(), PAYLOAD_DIR);
     }
 
@@ -74,6 +74,7 @@
     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 =
@@ -134,7 +135,7 @@
         };
     }
 
-    private static boolean resolvePathInVmConfig(Context context) {
+    public static boolean resolvePathInVmConfig(Context context) {
         try {
             String replacedVmConfig =
                     String.join(