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