Convert MainActivity to kotlin

Bug: 383243644
Test: Run main activity
Change-Id: I8f2cb0c5a8a0a185b36e4ecbdd59240d463a1e38
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.kt b/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.kt
index 31c9a91..86d9db6 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.kt
@@ -18,7 +18,7 @@
 import android.os.Build
 import android.os.Environment
 import android.util.Log
-import com.android.virtualization.terminal.MainActivity.TAG
+import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import java.io.BufferedInputStream
 import java.io.FileInputStream
 import java.io.IOException
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
index e52f996..f74e0ec 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
@@ -20,7 +20,7 @@
 import android.system.ErrnoException
 import android.system.Os
 import android.util.Log
-import com.android.virtualization.terminal.MainActivity.TAG
+import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import java.io.BufferedReader
 import java.io.FileReader
 import java.io.IOException
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.kt
index 3eff09b..2bd166e 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.kt
@@ -36,7 +36,7 @@
 import com.android.virtualization.terminal.ImageArchive.Companion.getDefault
 import com.android.virtualization.terminal.InstallerActivity.InstallProgressListener
 import com.android.virtualization.terminal.InstallerActivity.InstallerServiceConnection
-import com.android.virtualization.terminal.MainActivity.TAG
+import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import com.google.android.material.progressindicator.LinearProgressIndicator
 import com.google.android.material.snackbar.Snackbar
 import java.io.IOException
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.kt
index 9bd6a13..423d66b 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.kt
@@ -32,7 +32,7 @@
 import com.android.virtualization.terminal.InstalledImage.Companion.getDefault
 import com.android.virtualization.terminal.InstallerService.InstallerServiceImpl
 import com.android.virtualization.terminal.InstallerService.WifiCheckInputStream.NoWifiException
-import com.android.virtualization.terminal.MainActivity.TAG
+import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import java.io.IOException
 import java.io.InputStream
 import java.lang.Exception
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
deleted file mode 100644
index 4fddd14..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
+++ /dev/null
@@ -1,543 +0,0 @@
-/*
- * 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 static android.webkit.WebSettings.LOAD_NO_CACHE;
-
-import android.app.Notification;
-import android.app.PendingIntent;
-import android.content.Context;
-import android.content.Intent;
-import android.content.pm.ActivityInfo;
-import android.content.res.Configuration;
-import android.graphics.Bitmap;
-import android.graphics.drawable.Icon;
-import android.graphics.fonts.FontStyle;
-import android.net.Uri;
-import android.net.http.SslError;
-import android.net.nsd.NsdManager;
-import android.net.nsd.NsdServiceInfo;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.ConditionVariable;
-import android.os.Environment;
-import android.os.SystemProperties;
-import android.provider.Settings;
-import android.util.Log;
-import android.view.KeyEvent;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.WindowInsets;
-import android.view.accessibility.AccessibilityManager;
-import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener;
-import android.webkit.ClientCertRequest;
-import android.webkit.SslErrorHandler;
-import android.webkit.WebChromeClient;
-import android.webkit.WebResourceError;
-import android.webkit.WebResourceRequest;
-import android.webkit.WebView;
-import android.webkit.WebViewClient;
-
-import androidx.activity.result.ActivityResult;
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.activity.result.contract.ActivityResultContracts;
-import androidx.annotation.NonNull;
-
-import com.android.internal.annotations.VisibleForTesting;
-import com.android.microdroid.test.common.DeviceProperties;
-
-import com.google.android.material.appbar.MaterialToolbar;
-
-import java.io.IOException;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.security.KeyStore;
-import java.security.PrivateKey;
-import java.security.cert.X509Certificate;
-import java.util.Map;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
-public class MainActivity extends BaseActivity
-        implements VmLauncherService.VmLauncherServiceCallback, AccessibilityStateChangeListener {
-    static final String TAG = "VmTerminalApp";
-    static final String KEY_DISK_SIZE = "disk_size";
-    private static final int TERMINAL_CONNECTION_TIMEOUT_MS;
-    private static final int REQUEST_CODE_INSTALLER = 0x33;
-    private static final int FONT_SIZE_DEFAULT = 13;
-
-    static {
-        DeviceProperties prop = DeviceProperties.create(SystemProperties::get);
-        if (prop.isCuttlefish() || prop.isGoldfish()) {
-            TERMINAL_CONNECTION_TIMEOUT_MS = 180_000; // 3 minutes
-        } else {
-            TERMINAL_CONNECTION_TIMEOUT_MS = 20_000; // 20 sec
-        }
-    }
-
-    private ExecutorService mExecutorService;
-    private InstalledImage mImage;
-    private X509Certificate[] mCertificates;
-    private PrivateKey mPrivateKey;
-    private TerminalView mTerminalView;
-    private AccessibilityManager mAccessibilityManager;
-    private ConditionVariable mBootCompleted = new ConditionVariable();
-    private static final int POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE = 101;
-    private ActivityResultLauncher<Intent> mManageExternalStorageActivityResultLauncher;
-    private static final Map<Integer, Integer> BTN_KEY_CODE_MAP =
-            Map.ofEntries(
-                    Map.entry(R.id.btn_tab, KeyEvent.KEYCODE_TAB),
-                    // Alt key sends ESC keycode
-                    Map.entry(R.id.btn_alt, KeyEvent.KEYCODE_ESCAPE),
-                    Map.entry(R.id.btn_esc, KeyEvent.KEYCODE_ESCAPE),
-                    Map.entry(R.id.btn_left, KeyEvent.KEYCODE_DPAD_LEFT),
-                    Map.entry(R.id.btn_right, KeyEvent.KEYCODE_DPAD_RIGHT),
-                    Map.entry(R.id.btn_up, KeyEvent.KEYCODE_DPAD_UP),
-                    Map.entry(R.id.btn_down, KeyEvent.KEYCODE_DPAD_DOWN),
-                    Map.entry(R.id.btn_home, KeyEvent.KEYCODE_MOVE_HOME),
-                    Map.entry(R.id.btn_end, KeyEvent.KEYCODE_MOVE_END),
-                    Map.entry(R.id.btn_pgup, KeyEvent.KEYCODE_PAGE_UP),
-                    Map.entry(R.id.btn_pgdn, KeyEvent.KEYCODE_PAGE_DOWN));
-
-    @Override
-    protected void onCreate(Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-        lockOrientationIfNecessary();
-
-        mImage = InstalledImage.getDefault(this);
-
-        boolean launchInstaller = installIfNecessary();
-
-        setContentView(R.layout.activity_headless);
-
-        MaterialToolbar toolbar = (MaterialToolbar) findViewById(R.id.toolbar);
-        setSupportActionBar(toolbar);
-        mTerminalView = (TerminalView) findViewById(R.id.webview);
-        mTerminalView.getSettings().setDatabaseEnabled(true);
-        mTerminalView.getSettings().setDomStorageEnabled(true);
-        mTerminalView.getSettings().setJavaScriptEnabled(true);
-        mTerminalView.getSettings().setCacheMode(LOAD_NO_CACHE);
-        mTerminalView.setWebChromeClient(new WebChromeClient());
-
-        setupModifierKeys();
-
-        mAccessibilityManager = getSystemService(AccessibilityManager.class);
-        mAccessibilityManager.addAccessibilityStateChangeListener(this);
-
-        readClientCertificate();
-
-        mManageExternalStorageActivityResultLauncher =
-                registerForActivityResult(
-                        new ActivityResultContracts.StartActivityForResult(),
-                        (ActivityResult result) -> {
-                            startVm();
-                        });
-        getWindow()
-                .getDecorView()
-                .getRootView()
-                .setOnApplyWindowInsetsListener(
-                        (v, insets) -> {
-                            updateModifierKeysVisibility();
-                            return insets;
-                        });
-
-        mExecutorService =
-                Executors.newSingleThreadExecutor(
-                        new TerminalThreadFactory(getApplicationContext()));
-
-        // if installer is launched, it will be handled in onActivityResult
-        if (!launchInstaller) {
-            if (!Environment.isExternalStorageManager()) {
-                requestStoragePermissions(this, mManageExternalStorageActivityResultLauncher);
-            } else {
-                startVm();
-            }
-        }
-    }
-
-    private void lockOrientationIfNecessary() {
-        boolean hasHwQwertyKeyboard =
-                getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY;
-        if (hasHwQwertyKeyboard) {
-            setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
-        } else if (getResources().getBoolean(R.bool.terminal_portrait_only)) {
-            setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
-        }
-    }
-
-    @Override
-    public void onConfigurationChanged(@NonNull Configuration newConfig) {
-        super.onConfigurationChanged(newConfig);
-        lockOrientationIfNecessary();
-        updateModifierKeysVisibility();
-    }
-
-    private void setupModifierKeys() {
-        // Only ctrl key is special, it communicates with xtermjs to modify key event with ctrl key
-        findViewById(R.id.btn_ctrl)
-                .setOnClickListener(
-                        (v) -> {
-                            mTerminalView.mapCtrlKey();
-                            mTerminalView.enableCtrlKey();
-                        });
-
-        View.OnClickListener modifierButtonClickListener =
-                v -> {
-                    if (BTN_KEY_CODE_MAP.containsKey(v.getId())) {
-                        int keyCode = BTN_KEY_CODE_MAP.get(v.getId());
-                        mTerminalView.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, keyCode));
-                        mTerminalView.dispatchKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, keyCode));
-                    }
-                };
-
-        for (int btn : BTN_KEY_CODE_MAP.keySet()) {
-            View v = findViewById(btn);
-            if (v != null) {
-                v.setOnClickListener(modifierButtonClickListener);
-            }
-        }
-    }
-
-    @Override
-    public boolean dispatchKeyEvent(KeyEvent event) {
-        if (Build.isDebuggable() && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
-            if (event.getAction() == KeyEvent.ACTION_UP) {
-                ErrorActivity.start(this, new Exception("Debug: KeyEvent.KEYCODE_UNKNOWN"));
-            }
-            return true;
-        }
-        return super.dispatchKeyEvent(event);
-    }
-
-    private void requestStoragePermissions(
-            Context context, ActivityResultLauncher<Intent> activityResultLauncher) {
-        Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
-        Uri uri = Uri.fromParts("package", context.getPackageName(), null);
-        intent.setData(uri);
-        activityResultLauncher.launch(intent);
-    }
-
-    private URL getTerminalServiceUrl(String ipAddress, int port) {
-        Configuration config = getResources().getConfiguration();
-
-        String query =
-                "?fontSize="
-                        + (int) (config.fontScale * FONT_SIZE_DEFAULT)
-                        + "&fontWeight="
-                        + (FontStyle.FONT_WEIGHT_NORMAL + config.fontWeightAdjustment)
-                        + "&fontWeightBold="
-                        + (FontStyle.FONT_WEIGHT_BOLD + config.fontWeightAdjustment)
-                        + "&screenReaderMode="
-                        + mAccessibilityManager.isEnabled()
-                        + "&titleFixed="
-                        + getString(R.string.app_name);
-
-
-        try {
-            return new URL("https", ipAddress, port, "/" + query);
-        } catch (MalformedURLException e) {
-            // this cannot happen
-            return null;
-        }
-    }
-
-    private void readClientCertificate() {
-        KeyStore.PrivateKeyEntry pke = CertificateUtils.createOrGetKey();
-        CertificateUtils.writeCertificateToFile(this, pke.getCertificate());
-        mPrivateKey = pke.getPrivateKey();
-        mCertificates = new X509Certificate[1];
-        mCertificates[0] = (X509Certificate) pke.getCertificate();
-    }
-
-    private void connectToTerminalService() {
-        mTerminalView.setWebViewClient(
-                new WebViewClient() {
-                    private boolean mLoadFailed = false;
-                    private long mRequestId = 0;
-
-                    @Override
-                    public boolean shouldOverrideUrlLoading(
-                            WebView view, WebResourceRequest request) {
-                        return false;
-                    }
-
-                    @Override
-                    public void onPageStarted(WebView view, String url, Bitmap favicon) {
-                        mLoadFailed = false;
-                    }
-
-                    @Override
-                    public void onReceivedError(
-                            WebView view, WebResourceRequest request, WebResourceError error) {
-                        mLoadFailed = true;
-                        switch (error.getErrorCode()) {
-                            case WebViewClient.ERROR_CONNECT:
-                            case WebViewClient.ERROR_HOST_LOOKUP:
-                            case WebViewClient.ERROR_FAILED_SSL_HANDSHAKE:
-                            case WebViewClient.ERROR_TIMEOUT:
-                                view.reload();
-                                return;
-                            default:
-                                String url = request.getUrl().toString();
-                                CharSequence msg = error.getDescription();
-                                Log.e(TAG, "Failed to load " + url + ": " + msg);
-                        }
-                    }
-
-                    @Override
-                    public void onPageFinished(WebView view, String url) {
-                        if (mLoadFailed) {
-                            return;
-                        }
-
-                        mRequestId++;
-                        view.postVisualStateCallback(
-                                mRequestId,
-                                new WebView.VisualStateCallback() {
-                                    @Override
-                                    public void onComplete(long requestId) {
-                                        if (requestId == mRequestId) {
-                                            android.os.Trace.endAsyncSection("executeTerminal", 0);
-                                            findViewById(R.id.boot_progress)
-                                                    .setVisibility(View.GONE);
-                                            findViewById(R.id.webview_container)
-                                                    .setVisibility(View.VISIBLE);
-                                            mBootCompleted.open();
-                                            updateModifierKeysVisibility();
-                                            mTerminalView.mapTouchToMouseEvent();
-                                        }
-                                    }
-                                });
-                    }
-
-                    @Override
-                    public void onReceivedClientCertRequest(
-                            WebView view, ClientCertRequest request) {
-                        if (mPrivateKey != null && mCertificates != null) {
-                            request.proceed(mPrivateKey, mCertificates);
-                            return;
-                        }
-                        super.onReceivedClientCertRequest(view, request);
-                    }
-
-                    @Override
-                    public void onReceivedSslError(
-                            WebView view, SslErrorHandler handler, SslError error) {
-                        // ttyd uses self-signed certificate
-                        handler.proceed();
-                    }
-                });
-
-        // TODO: refactor this block as a method
-        NsdManager nsdManager = getSystemService(NsdManager.class);
-        NsdServiceInfo info = new NsdServiceInfo();
-        info.setServiceType("_http._tcp");
-        info.setServiceName("ttyd");
-        nsdManager.registerServiceInfoCallback(
-                info,
-                mExecutorService,
-                new NsdManager.ServiceInfoCallback() {
-                    @Override
-                    public void onServiceInfoCallbackRegistrationFailed(int errorCode) {}
-
-                    @Override
-                    public void onServiceInfoCallbackUnregistered() {}
-
-                    @Override
-                    public void onServiceLost() {}
-
-                    @Override
-                    public void onServiceUpdated(NsdServiceInfo info) {
-                        nsdManager.unregisterServiceInfoCallback(this);
-
-                        Log.i(TAG, "Service found: " + info.toString());
-                        String ipAddress = info.getHostAddresses().get(0).getHostAddress();
-                        int port = info.getPort();
-                        URL url = getTerminalServiceUrl(ipAddress, port);
-                        runOnUiThread(() -> mTerminalView.loadUrl(url.toString()));
-                    }
-                });
-    }
-
-    @Override
-    protected void onDestroy() {
-        if (mExecutorService != null) {
-            mExecutorService.shutdown();
-        }
-
-        getSystemService(AccessibilityManager.class).removeAccessibilityStateChangeListener(this);
-        VmLauncherService.stop(this);
-        super.onDestroy();
-    }
-
-    @Override
-    public void onVmStart() {
-        Log.i(TAG, "onVmStart()");
-    }
-
-    @Override
-    public void onVmStop() {
-        Log.i(TAG, "onVmStop()");
-        finish();
-    }
-
-    @Override
-    public void onVmError() {
-        Log.i(TAG, "onVmError()");
-        // TODO: error cause is too simple.
-        ErrorActivity.start(this, new Exception("onVmError"));
-    }
-
-    @Override
-    public boolean onCreateOptionsMenu(Menu menu) {
-        getMenuInflater().inflate(R.menu.main_menu, menu);
-        return true;
-    }
-
-    @Override
-    public boolean onOptionsItemSelected(MenuItem item) {
-        int id = item.getItemId();
-        if (id == R.id.menu_item_settings) {
-            Intent intent = new Intent(this, SettingsActivity.class);
-            this.startActivity(intent);
-            return true;
-        }
-        return super.onOptionsItemSelected(item);
-    }
-
-    @Override
-    public void onAccessibilityStateChanged(boolean enabled) {
-        connectToTerminalService();
-    }
-
-    private void updateModifierKeysVisibility() {
-        boolean imeShown =
-                getWindow().getDecorView().getRootWindowInsets().isVisible(WindowInsets.Type.ime());
-        boolean hasHwQwertyKeyboard =
-                getResources().getConfiguration().keyboard == Configuration.KEYBOARD_QWERTY;
-        boolean showModifierKeys = imeShown && !hasHwQwertyKeyboard;
-
-        View modifierKeys = findViewById(R.id.modifier_keys);
-        modifierKeys.setVisibility(showModifierKeys ? View.VISIBLE : View.GONE);
-    }
-
-    @Override
-    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
-        super.onActivityResult(requestCode, resultCode, data);
-
-        if (requestCode == REQUEST_CODE_INSTALLER) {
-            if (resultCode != RESULT_OK) {
-                Log.e(TAG, "Failed to start VM. Installer returned error.");
-                finish();
-            }
-            if (!Environment.isExternalStorageManager()) {
-                requestStoragePermissions(this, mManageExternalStorageActivityResultLauncher);
-            } else {
-                startVm();
-            }
-        }
-    }
-
-    private boolean installIfNecessary() {
-        // If payload from external storage exists(only for debuggable build) or there is no
-        // installed image, launch installer activity.
-        if (!mImage.isInstalled()) {
-            Intent intent = new Intent(this, InstallerActivity.class);
-            startActivityForResult(intent, REQUEST_CODE_INSTALLER);
-            return true;
-        }
-        return false;
-    }
-
-    private void startVm() {
-        InstalledImage image = InstalledImage.getDefault(this);
-        if (!image.isInstalled()) {
-            return;
-        }
-
-        resizeDiskIfNecessary(image);
-
-        Intent tapIntent = new Intent(this, MainActivity.class);
-        tapIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
-        PendingIntent tapPendingIntent =
-                PendingIntent.getActivity(this, 0, tapIntent, PendingIntent.FLAG_IMMUTABLE);
-
-        Intent settingsIntent = new Intent(this, SettingsActivity.class);
-        settingsIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
-        PendingIntent settingsPendingIntent = PendingIntent.getActivity(this, 0, settingsIntent,
-                PendingIntent.FLAG_IMMUTABLE);
-
-        Intent stopIntent = new Intent();
-        stopIntent.setClass(this, VmLauncherService.class);
-        stopIntent.setAction(VmLauncherService.ACTION_STOP_VM_LAUNCHER_SERVICE);
-        PendingIntent stopPendingIntent =
-                PendingIntent.getService(
-                        this,
-                        0,
-                        stopIntent,
-                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
-        Icon icon = Icon.createWithResource(getResources(), R.drawable.ic_launcher_foreground);
-        Notification notification =
-                new Notification.Builder(this, this.getPackageName())
-                        .setSilent(true)
-                        .setSmallIcon(R.drawable.ic_launcher_foreground)
-                        .setContentTitle(
-                                getResources().getString(R.string.service_notification_title))
-                        .setContentText(
-                                getResources().getString(R.string.service_notification_content))
-                        .setContentIntent(tapPendingIntent)
-                        .setOngoing(true)
-                        .addAction(
-                                new Notification.Action.Builder(
-                                                icon,
-                                                getResources()
-                                                        .getString(
-                                                                R.string
-                                                                        .service_notification_settings),
-                                                settingsPendingIntent)
-                                        .build())
-                        .addAction(
-                                new Notification.Action.Builder(
-                                                icon,
-                                                getResources()
-                                                        .getString(
-                                                                R.string
-                                                                        .service_notification_quit_action),
-                                                stopPendingIntent)
-                                        .build())
-                        .build();
-
-        android.os.Trace.beginAsyncSection("executeTerminal", 0);
-        VmLauncherService.run(this, this, notification);
-        connectToTerminalService();
-    }
-
-    @VisibleForTesting
-    public boolean waitForBootCompleted(long timeoutMillis) {
-        return mBootCompleted.block(timeoutMillis);
-    }
-
-    private void resizeDiskIfNecessary(InstalledImage image) {
-        try {
-            // TODO(b/382190982): Show snackbar message instead when it's recoverable.
-            image.resize(getIntent().getLongExtra(KEY_DISK_SIZE, image.getSize()));
-        } catch (IOException e) {
-            ErrorActivity.start(this, new Exception("Failed to resize disk", e));
-            return;
-        }
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
new file mode 100644
index 0000000..b90115f
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
@@ -0,0 +1,529 @@
+/*
+ * 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.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ActivityInfo
+import android.content.res.Configuration
+import android.graphics.Bitmap
+import android.graphics.drawable.Icon
+import android.graphics.fonts.FontStyle
+import android.net.Uri
+import android.net.http.SslError
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.os.Build
+import android.os.Bundle
+import android.os.ConditionVariable
+import android.os.Environment
+import android.os.SystemProperties
+import android.os.Trace
+import android.provider.Settings
+import android.util.Log
+import android.view.KeyEvent
+import android.view.Menu
+import android.view.MenuItem
+import android.view.View
+import android.view.WindowInsets
+import android.view.accessibility.AccessibilityManager
+import android.webkit.ClientCertRequest
+import android.webkit.SslErrorHandler
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.activity.result.ActivityResult
+import androidx.activity.result.ActivityResultCallback
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import com.android.internal.annotations.VisibleForTesting
+import com.android.microdroid.test.common.DeviceProperties
+import com.android.virtualization.terminal.CertificateUtils.createOrGetKey
+import com.android.virtualization.terminal.CertificateUtils.writeCertificateToFile
+import com.android.virtualization.terminal.ErrorActivity.Companion.start
+import com.android.virtualization.terminal.InstalledImage.Companion.getDefault
+import com.android.virtualization.terminal.VmLauncherService.Companion.run
+import com.android.virtualization.terminal.VmLauncherService.Companion.stop
+import com.android.virtualization.terminal.VmLauncherService.VmLauncherServiceCallback
+import com.google.android.material.appbar.MaterialToolbar
+import java.io.IOException
+import java.lang.Exception
+import java.net.MalformedURLException
+import java.net.URL
+import java.security.PrivateKey
+import java.security.cert.X509Certificate
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+class MainActivity :
+    BaseActivity(),
+    VmLauncherServiceCallback,
+    AccessibilityManager.AccessibilityStateChangeListener {
+    private lateinit var executorService: ExecutorService
+    private lateinit var image: InstalledImage
+    private var certificates: Array<X509Certificate>? = null
+    private var privateKey: PrivateKey? = null
+    private lateinit var terminalView: TerminalView
+    private lateinit var accessibilityManager: AccessibilityManager
+    private val bootCompleted = ConditionVariable()
+    private lateinit var manageExternalStorageActivityResultLauncher: ActivityResultLauncher<Intent>
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        lockOrientationIfNecessary()
+
+        image = getDefault(this)
+
+        val launchInstaller = installIfNecessary()
+
+        setContentView(R.layout.activity_headless)
+
+        val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
+        setSupportActionBar(toolbar)
+        terminalView = findViewById<TerminalView>(R.id.webview)
+        terminalView.getSettings().setDatabaseEnabled(true)
+        terminalView.getSettings().setDomStorageEnabled(true)
+        terminalView.getSettings().setJavaScriptEnabled(true)
+        terminalView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE)
+        terminalView.setWebChromeClient(WebChromeClient())
+
+        setupModifierKeys()
+
+        accessibilityManager =
+            getSystemService<AccessibilityManager>(AccessibilityManager::class.java)
+        accessibilityManager.addAccessibilityStateChangeListener(this)
+
+        readClientCertificate()
+
+        manageExternalStorageActivityResultLauncher =
+            registerForActivityResult<Intent, ActivityResult>(
+                StartActivityForResult(),
+                ActivityResultCallback { startVm() },
+            )
+        window.decorView.rootView.setOnApplyWindowInsetsListener { _: View?, insets: WindowInsets ->
+            updateModifierKeysVisibility()
+            insets
+        }
+
+        executorService =
+            Executors.newSingleThreadExecutor(TerminalThreadFactory(applicationContext))
+
+        // if installer is launched, it will be handled in onActivityResult
+        if (!launchInstaller) {
+            if (!Environment.isExternalStorageManager()) {
+                requestStoragePermissions(this, manageExternalStorageActivityResultLauncher)
+            } else {
+                startVm()
+            }
+        }
+    }
+
+    private fun lockOrientationIfNecessary() {
+        val hasHwQwertyKeyboard = resources.configuration.keyboard == Configuration.KEYBOARD_QWERTY
+        if (hasHwQwertyKeyboard) {
+            setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
+        } else if (resources.getBoolean(R.bool.terminal_portrait_only)) {
+            setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT)
+        }
+    }
+
+    override fun onConfigurationChanged(newConfig: Configuration) {
+        super.onConfigurationChanged(newConfig)
+        lockOrientationIfNecessary()
+        updateModifierKeysVisibility()
+    }
+
+    private fun setupModifierKeys() {
+        // Only ctrl key is special, it communicates with xtermjs to modify key event with ctrl key
+        findViewById<View>(R.id.btn_ctrl)
+            .setOnClickListener(
+                View.OnClickListener {
+                    terminalView.mapCtrlKey()
+                    terminalView.enableCtrlKey()
+                }
+            )
+
+        val modifierButtonClickListener =
+            View.OnClickListener { v: View ->
+                BTN_KEY_CODE_MAP[v.id]?.also { keyCode ->
+                    terminalView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
+                    terminalView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode))
+                }
+            }
+
+        for (btn in BTN_KEY_CODE_MAP.keys) {
+            findViewById<View>(btn).setOnClickListener(modifierButtonClickListener)
+        }
+    }
+
+    override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+        if (Build.isDebuggable() && event.keyCode == KeyEvent.KEYCODE_UNKNOWN) {
+            if (event.action == KeyEvent.ACTION_UP) {
+                start(this, Exception("Debug: KeyEvent.KEYCODE_UNKNOWN"))
+            }
+            return true
+        }
+        return super.dispatchKeyEvent(event)
+    }
+
+    private fun requestStoragePermissions(
+        context: Context,
+        activityResultLauncher: ActivityResultLauncher<Intent>,
+    ) {
+        val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
+        val uri = Uri.fromParts("package", context.getPackageName(), null)
+        intent.setData(uri)
+        activityResultLauncher.launch(intent)
+    }
+
+    private fun getTerminalServiceUrl(ipAddress: String?, port: Int): URL? {
+        val config = resources.configuration
+
+        val query =
+            ("?fontSize=" +
+                (config.fontScale * FONT_SIZE_DEFAULT).toInt() +
+                "&fontWeight=" +
+                (FontStyle.FONT_WEIGHT_NORMAL + config.fontWeightAdjustment) +
+                "&fontWeightBold=" +
+                (FontStyle.FONT_WEIGHT_BOLD + config.fontWeightAdjustment) +
+                "&screenReaderMode=" +
+                accessibilityManager.isEnabled +
+                "&titleFixed=" +
+                getString(R.string.app_name))
+
+        try {
+            return URL("https", ipAddress, port, "/$query")
+        } catch (e: MalformedURLException) {
+            // this cannot happen
+            return null
+        }
+    }
+
+    private fun readClientCertificate() {
+        val pke = createOrGetKey()
+        writeCertificateToFile(this, pke.certificate)
+        privateKey = pke.privateKey
+        certificates = arrayOf<X509Certificate>(pke.certificate as X509Certificate)
+    }
+
+    private fun connectToTerminalService() {
+        terminalView.setWebViewClient(
+            object : WebViewClient() {
+                private var loadFailed = false
+                private var requestId: Long = 0
+
+                override fun shouldOverrideUrlLoading(
+                    view: WebView?,
+                    request: WebResourceRequest?,
+                ): Boolean {
+                    return false
+                }
+
+                override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+                    loadFailed = false
+                }
+
+                override fun onReceivedError(
+                    view: WebView,
+                    request: WebResourceRequest,
+                    error: WebResourceError,
+                ) {
+                    loadFailed = true
+                    when (error.getErrorCode()) {
+                        ERROR_CONNECT,
+                        ERROR_HOST_LOOKUP,
+                        ERROR_FAILED_SSL_HANDSHAKE,
+                        ERROR_TIMEOUT -> {
+                            view.reload()
+                            return
+                        }
+
+                        else -> {
+                            val url: String? = request.getUrl().toString()
+                            val msg = error.getDescription()
+                            Log.e(TAG, "Failed to load $url: $msg")
+                        }
+                    }
+                }
+
+                override fun onPageFinished(view: WebView, url: String?) {
+                    if (loadFailed) {
+                        return
+                    }
+
+                    requestId++
+                    view.postVisualStateCallback(
+                        requestId,
+                        object : WebView.VisualStateCallback() {
+                            override fun onComplete(completedRequestId: Long) {
+                                if (completedRequestId == requestId) {
+                                    Trace.endAsyncSection("executeTerminal", 0)
+                                    findViewById<View?>(R.id.boot_progress).visibility = View.GONE
+                                    findViewById<View?>(R.id.webview_container).visibility =
+                                        View.VISIBLE
+                                    bootCompleted.open()
+                                    updateModifierKeysVisibility()
+                                    terminalView.mapTouchToMouseEvent()
+                                }
+                            }
+                        },
+                    )
+                }
+
+                override fun onReceivedClientCertRequest(
+                    view: WebView?,
+                    request: ClientCertRequest,
+                ) {
+                    if (privateKey != null && certificates != null) {
+                        request.proceed(privateKey, certificates)
+                        return
+                    }
+                    super.onReceivedClientCertRequest(view, request)
+                }
+
+                override fun onReceivedSslError(
+                    view: WebView?,
+                    handler: SslErrorHandler,
+                    error: SslError?,
+                ) {
+                    // ttyd uses self-signed certificate
+                    handler.proceed()
+                }
+            }
+        )
+
+        // TODO: refactor this block as a method
+        val nsdManager = getSystemService<NsdManager>(NsdManager::class.java)
+        val info = NsdServiceInfo()
+        info.serviceType = "_http._tcp"
+        info.serviceName = "ttyd"
+        nsdManager.registerServiceInfoCallback(
+            info,
+            executorService,
+            object : NsdManager.ServiceInfoCallback {
+                override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {}
+
+                override fun onServiceInfoCallbackUnregistered() {}
+
+                override fun onServiceLost() {}
+
+                override fun onServiceUpdated(info: NsdServiceInfo) {
+                    nsdManager.unregisterServiceInfoCallback(this)
+
+                    Log.i(TAG, "Service found: $info")
+                    val ipAddress = info.hostAddresses[0].hostAddress
+                    val port = info.port
+                    val url = getTerminalServiceUrl(ipAddress, port)
+                    runOnUiThread(Runnable { terminalView.loadUrl(url.toString()) })
+                }
+            },
+        )
+    }
+
+    override fun onDestroy() {
+        executorService.shutdown()
+        getSystemService<AccessibilityManager>(AccessibilityManager::class.java)
+            .removeAccessibilityStateChangeListener(this)
+        stop(this)
+        super.onDestroy()
+    }
+
+    override fun onVmStart() {
+        Log.i(TAG, "onVmStart()")
+    }
+
+    override fun onVmStop() {
+        Log.i(TAG, "onVmStop()")
+        finish()
+    }
+
+    override fun onVmError() {
+        Log.i(TAG, "onVmError()")
+        // TODO: error cause is too simple.
+        start(this, Exception("onVmError"))
+    }
+
+    override fun onCreateOptionsMenu(menu: Menu?): Boolean {
+        menuInflater.inflate(R.menu.main_menu, menu)
+        return true
+    }
+
+    override fun onOptionsItemSelected(item: MenuItem): Boolean {
+        val id = item.getItemId()
+        if (id == R.id.menu_item_settings) {
+            val intent = Intent(this, SettingsActivity::class.java)
+            this.startActivity(intent)
+            return true
+        }
+        return super.onOptionsItemSelected(item)
+    }
+
+    override fun onAccessibilityStateChanged(enabled: Boolean) {
+        connectToTerminalService()
+    }
+
+    private fun updateModifierKeysVisibility() {
+        val imeShown = window.decorView.rootWindowInsets.isVisible(WindowInsets.Type.ime())
+        val hasHwQwertyKeyboard = resources.configuration.keyboard == Configuration.KEYBOARD_QWERTY
+        val showModifierKeys = imeShown && !hasHwQwertyKeyboard
+
+        val modifierKeys = findViewById<View>(R.id.modifier_keys)
+        modifierKeys.visibility = if (showModifierKeys) View.VISIBLE else View.GONE
+    }
+
+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+        super.onActivityResult(requestCode, resultCode, data)
+
+        if (requestCode == REQUEST_CODE_INSTALLER) {
+            if (resultCode != RESULT_OK) {
+                Log.e(TAG, "Failed to start VM. Installer returned error.")
+                finish()
+            }
+            if (!Environment.isExternalStorageManager()) {
+                requestStoragePermissions(this, manageExternalStorageActivityResultLauncher)
+            } else {
+                startVm()
+            }
+        }
+    }
+
+    private fun installIfNecessary(): Boolean {
+        // If payload from external storage exists(only for debuggable build) or there is no
+        // installed image, launch installer activity.
+        if (!image.isInstalled()) {
+            val intent = Intent(this, InstallerActivity::class.java)
+            startActivityForResult(intent, REQUEST_CODE_INSTALLER)
+            return true
+        }
+        return false
+    }
+
+    private fun startVm() {
+        val image = getDefault(this)
+        if (!image.isInstalled()) {
+            return
+        }
+
+        resizeDiskIfNecessary(image)
+
+        val tapIntent = Intent(this, MainActivity::class.java)
+        tapIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+        val tapPendingIntent =
+            PendingIntent.getActivity(this, 0, tapIntent, PendingIntent.FLAG_IMMUTABLE)
+
+        val settingsIntent = Intent(this, SettingsActivity::class.java)
+        settingsIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+        val settingsPendingIntent =
+            PendingIntent.getActivity(this, 0, settingsIntent, PendingIntent.FLAG_IMMUTABLE)
+
+        val stopIntent = Intent()
+        stopIntent.setClass(this, VmLauncherService::class.java)
+        stopIntent.setAction(VmLauncherService.ACTION_STOP_VM_LAUNCHER_SERVICE)
+        val stopPendingIntent =
+            PendingIntent.getService(
+                this,
+                0,
+                stopIntent,
+                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+            )
+        val icon = Icon.createWithResource(resources, R.drawable.ic_launcher_foreground)
+        val notification: Notification =
+            Notification.Builder(this, this.packageName)
+                .setSilent(true)
+                .setSmallIcon(R.drawable.ic_launcher_foreground)
+                .setContentTitle(resources.getString(R.string.service_notification_title))
+                .setContentText(resources.getString(R.string.service_notification_content))
+                .setContentIntent(tapPendingIntent)
+                .setOngoing(true)
+                .addAction(
+                    Notification.Action.Builder(
+                            icon,
+                            resources.getString(R.string.service_notification_settings),
+                            settingsPendingIntent,
+                        )
+                        .build()
+                )
+                .addAction(
+                    Notification.Action.Builder(
+                            icon,
+                            resources.getString(R.string.service_notification_quit_action),
+                            stopPendingIntent,
+                        )
+                        .build()
+                )
+                .build()
+
+        Trace.beginAsyncSection("executeTerminal", 0)
+        run(this, this, notification)
+        connectToTerminalService()
+    }
+
+    @VisibleForTesting
+    fun waitForBootCompleted(timeoutMillis: Long): Boolean {
+        return bootCompleted.block(timeoutMillis)
+    }
+
+    private fun resizeDiskIfNecessary(image: InstalledImage) {
+        try {
+            // TODO(b/382190982): Show snackbar message instead when it's recoverable.
+            image.resize(intent.getLongExtra(KEY_DISK_SIZE, image.getSize()))
+        } catch (e: IOException) {
+            start(this, Exception("Failed to resize disk", e))
+            return
+        }
+    }
+
+    companion object {
+        const val TAG: String = "VmTerminalApp"
+        const val KEY_DISK_SIZE: String = "disk_size"
+        private val TERMINAL_CONNECTION_TIMEOUT_MS: Int
+        private const val REQUEST_CODE_INSTALLER = 0x33
+        private const val FONT_SIZE_DEFAULT = 13
+
+        init {
+            val prop =
+                DeviceProperties.create(
+                    DeviceProperties.PropertyGetter { key: String -> SystemProperties.get(key) }
+                )
+            TERMINAL_CONNECTION_TIMEOUT_MS =
+                if (prop.isCuttlefish() || prop.isGoldfish()) {
+                    180000 // 3 minutes
+                } else {
+                    20000 // 20 sec
+                }
+        }
+
+        private val BTN_KEY_CODE_MAP =
+            mapOf(
+                R.id.btn_tab to KeyEvent.KEYCODE_TAB, // Alt key sends ESC keycode
+                R.id.btn_alt to KeyEvent.KEYCODE_ESCAPE,
+                R.id.btn_esc to KeyEvent.KEYCODE_ESCAPE,
+                R.id.btn_left to KeyEvent.KEYCODE_DPAD_LEFT,
+                R.id.btn_right to KeyEvent.KEYCODE_DPAD_RIGHT,
+                R.id.btn_up to KeyEvent.KEYCODE_DPAD_UP,
+                R.id.btn_down to KeyEvent.KEYCODE_DPAD_DOWN,
+                R.id.btn_home to KeyEvent.KEYCODE_MOVE_HOME,
+                R.id.btn_end to KeyEvent.KEYCODE_MOVE_END,
+                R.id.btn_pgup to KeyEvent.KEYCODE_PAGE_UP,
+                R.id.btn_pgdn to KeyEvent.KEYCODE_PAGE_DOWN,
+            )
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt b/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt
index 30729c4..7c48303 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt
@@ -23,7 +23,7 @@
 import android.content.Intent
 import android.content.IntentFilter
 import android.graphics.drawable.Icon
-import com.android.virtualization.terminal.MainActivity.TAG
+import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import java.util.Locale
 
 /**
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/Runner.kt b/android/TerminalApp/java/com/android/virtualization/terminal/Runner.kt
index 897e182..55268f8 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/Runner.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/Runner.kt
@@ -22,7 +22,7 @@
 import android.system.virtualmachine.VirtualMachineException
 import android.system.virtualmachine.VirtualMachineManager
 import android.util.Log
-import com.android.virtualization.terminal.MainActivity.TAG
+import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import java.util.concurrent.CompletableFuture
 import java.util.concurrent.ForkJoinPool
 
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
index a4d43b8..319a53b 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
@@ -22,7 +22,7 @@
 import androidx.appcompat.app.AppCompatActivity
 import androidx.core.view.isVisible
 import androidx.lifecycle.lifecycleScope
-import com.android.virtualization.terminal.MainActivity.TAG
+import com.android.virtualization.terminal.MainActivity.Companion.TAG
 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/TerminalView.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
index 0f990c5..4d9a89d 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
@@ -31,7 +31,7 @@
 import android.view.inputmethod.EditorInfo
 import android.view.inputmethod.InputConnection
 import android.webkit.WebView
-import com.android.virtualization.terminal.MainActivity.TAG
+import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import java.io.IOException
 
 class TerminalView(context: Context, attrs: AttributeSet?) :
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
index 5271e8b..966c4a6 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
@@ -36,7 +36,7 @@
 import android.system.virtualmachine.VirtualMachineException
 import android.util.Log
 import android.widget.Toast
-import com.android.virtualization.terminal.MainActivity.TAG
+import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import com.android.virtualization.terminal.Runner.Companion.create
 import com.android.virtualization.terminal.VmLauncherService.VmLauncherServiceCallback
 import io.grpc.Grpc