Merge "pvmfw: suppress error about unused -c argument" into main
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.java b/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.java
deleted file mode 100644
index e3d1a67..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.java
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.virtualization.terminal;
-
-import static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.content.Context;
-import android.security.keystore.KeyGenParameterSpec;
-import android.security.keystore.KeyProperties;
-import android.util.Base64;
-import android.util.Log;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.security.InvalidAlgorithmParameterException;
-import java.security.KeyPairGenerator;
-import java.security.KeyStore;
-import java.security.NoSuchAlgorithmException;
-import java.security.NoSuchProviderException;
-import java.security.cert.Certificate;
-import java.security.cert.CertificateEncodingException;
-import java.security.cert.CertificateExpiredException;
-import java.security.cert.CertificateNotYetValidException;
-import java.security.cert.X509Certificate;
-
-public class CertificateUtils {
- private static final String ALIAS = "ttyd";
-
- public static KeyStore.PrivateKeyEntry createOrGetKey() {
- try {
- KeyStore ks = KeyStore.getInstance("AndroidKeyStore");
- ks.load(null);
-
- if (!ks.containsAlias(ALIAS)) {
- Log.d(TAG, "there is no keypair, will generate it");
- createKey();
- } else if (!(ks.getCertificate(ALIAS) instanceof X509Certificate)) {
- Log.d(TAG, "certificate isn't X509Certificate or it is invalid");
- createKey();
- } else {
- try {
- ((X509Certificate) ks.getCertificate(ALIAS)).checkValidity();
- } catch (CertificateExpiredException | CertificateNotYetValidException e) {
- Log.d(TAG, "certificate is invalid", e);
- createKey();
- }
- }
- return ((KeyStore.PrivateKeyEntry) ks.getEntry(ALIAS, null));
- } catch (Exception e) {
- throw new RuntimeException("cannot generate or get key", e);
- }
- }
-
- private static void createKey()
- throws NoSuchAlgorithmException,
- NoSuchProviderException,
- InvalidAlgorithmParameterException {
- KeyPairGenerator kpg =
- KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore");
- kpg.initialize(
- new KeyGenParameterSpec.Builder(
- ALIAS, KeyProperties.PURPOSE_SIGN | KeyProperties.PURPOSE_VERIFY)
- .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
- .build());
-
- kpg.generateKeyPair();
- }
-
- public static void writeCertificateToFile(Context context, Certificate cert) {
- String certFileName = "ca.crt";
- File certFile = new File(context.getFilesDir(), certFileName);
- try (FileOutputStream writer = new FileOutputStream(certFile)) {
- String cert_begin = "-----BEGIN CERTIFICATE-----\n";
- String end_cert = "-----END CERTIFICATE-----\n";
- String output =
- cert_begin
- + Base64.encodeToString(cert.getEncoded(), Base64.DEFAULT)
- .replaceAll("(.{64})", "$1\n")
- + end_cert;
- writer.write(output.getBytes());
- } catch (IOException | CertificateEncodingException e) {
- throw new RuntimeException("cannot write certs", e);
- }
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.kt b/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.kt
new file mode 100644
index 0000000..53809e5
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.kt
@@ -0,0 +1,110 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import android.util.Log
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.lang.Exception
+import java.lang.RuntimeException
+import java.security.InvalidAlgorithmParameterException
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.NoSuchAlgorithmException
+import java.security.NoSuchProviderException
+import java.security.cert.Certificate
+import java.security.cert.CertificateEncodingException
+import java.security.cert.CertificateExpiredException
+import java.security.cert.CertificateNotYetValidException
+import java.security.cert.X509Certificate
+
+object CertificateUtils {
+ private const val ALIAS = "ttyd"
+
+ @JvmStatic
+ fun createOrGetKey(): KeyStore.PrivateKeyEntry {
+ try {
+ val ks = KeyStore.getInstance("AndroidKeyStore")
+ ks.load(null)
+
+ if (!ks.containsAlias(ALIAS)) {
+ Log.d(MainActivity.TAG, "there is no keypair, will generate it")
+ createKey()
+ } else if (ks.getCertificate(ALIAS) !is X509Certificate) {
+ Log.d(MainActivity.TAG, "certificate isn't X509Certificate or it is invalid")
+ createKey()
+ } else {
+ try {
+ (ks.getCertificate(ALIAS) as X509Certificate).checkValidity()
+ } catch (e: CertificateExpiredException) {
+ Log.d(MainActivity.TAG, "certificate is invalid", e)
+ createKey()
+ } catch (e: CertificateNotYetValidException) {
+ Log.d(MainActivity.TAG, "certificate is invalid", e)
+ createKey()
+ }
+ }
+ return ks.getEntry(ALIAS, null) as KeyStore.PrivateKeyEntry
+ } catch (e: Exception) {
+ throw RuntimeException("cannot generate or get key", e)
+ }
+ }
+
+ @Throws(
+ NoSuchAlgorithmException::class,
+ NoSuchProviderException::class,
+ InvalidAlgorithmParameterException::class,
+ )
+ private fun createKey() {
+ val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
+ kpg.initialize(
+ KeyGenParameterSpec.Builder(
+ ALIAS,
+ KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
+ )
+ .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
+ .build()
+ )
+
+ kpg.generateKeyPair()
+ }
+
+ @JvmStatic
+ fun writeCertificateToFile(context: Context, cert: Certificate) {
+ val certFile = File(context.getFilesDir(), "ca.crt")
+ try {
+ FileOutputStream(certFile).use { writer ->
+ val certBegin = "-----BEGIN CERTIFICATE-----\n"
+ val certEnd = "-----END CERTIFICATE-----\n"
+ val output =
+ (certBegin +
+ Base64.encodeToString(cert.encoded, Base64.DEFAULT)
+ .replace("(.{64})".toRegex(), "$1\n") +
+ certEnd)
+ writer.write(output.toByteArray())
+ }
+ } catch (e: IOException) {
+ throw RuntimeException("cannot write certs", e)
+ } catch (e: CertificateEncodingException) {
+ throw RuntimeException("cannot write certs", e)
+ }
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.java
deleted file mode 100644
index 7099f22..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.java
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.virtualization.terminal;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.View;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.io.StringWriter;
-
-public class ErrorActivity extends BaseActivity {
- private static final String EXTRA_CAUSE = "cause";
-
- public static void start(Context context, Exception e) {
- Intent intent = new Intent(context, ErrorActivity.class);
- intent.putExtra(EXTRA_CAUSE, e);
- intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
- context.startActivity(intent);
- }
-
- @Override
- protected void onCreate(@Nullable Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- setContentView(R.layout.activity_error);
-
- View button = findViewById(R.id.recovery);
- button.setOnClickListener((event) -> launchRecoveryActivity());
- }
-
- @Override
- protected void onNewIntent(@NonNull Intent intent) {
- super.onNewIntent(intent);
- setIntent(intent);
- }
-
- @Override
- public void onResume() {
- super.onResume();
-
- Intent intent = getIntent();
- Exception e = intent.getParcelableExtra(EXTRA_CAUSE, Exception.class);
- TextView cause = findViewById(R.id.cause);
- if (e != null) {
- String stackTrace = getStackTrace(e);
- cause.setText(getString(R.string.error_code, stackTrace));
- } else {
- cause.setText(null);
- }
- }
-
- private void launchRecoveryActivity() {
- Intent intent = new Intent(this, SettingsRecoveryActivity.class);
- startActivity(intent);
- }
-
- private static String getStackTrace(Exception e) {
- try (StringWriter sWriter = new StringWriter();
- PrintWriter pWriter = new PrintWriter(sWriter)) {
- e.printStackTrace(pWriter);
- return sWriter.toString();
- } catch (IOException ex) {
- // This shall never happen
- throw new RuntimeException(ex);
- }
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.kt
new file mode 100644
index 0000000..f253f86
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.kt
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.widget.TextView
+import java.io.IOException
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.lang.Exception
+import java.lang.RuntimeException
+
+class ErrorActivity : BaseActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContentView(R.layout.activity_error)
+
+ val button = findViewById<View>(R.id.recovery)
+ button.setOnClickListener(View.OnClickListener { _ -> launchRecoveryActivity() })
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ }
+
+ override fun onResume() {
+ super.onResume()
+
+ val intent = getIntent()
+ val e = intent.getParcelableExtra<Exception?>(EXTRA_CAUSE, Exception::class.java)
+ val cause = findViewById<TextView>(R.id.cause)
+ cause.text = e?.let { getString(R.string.error_code, getStackTrace(it)) }
+ }
+
+ private fun launchRecoveryActivity() {
+ val intent = Intent(this, SettingsRecoveryActivity::class.java)
+ startActivity(intent)
+ }
+
+ companion object {
+ private const val EXTRA_CAUSE = "cause"
+
+ @JvmStatic
+ fun start(context: Context, e: Exception) {
+ val intent = Intent(context, ErrorActivity::class.java)
+ intent.putExtra(EXTRA_CAUSE, e)
+ intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ }
+
+ private fun getStackTrace(e: Exception): String? {
+ try {
+ StringWriter().use { sWriter ->
+ PrintWriter(sWriter).use { pWriter ->
+ e.printStackTrace(pWriter)
+ return sWriter.toString()
+ }
+ }
+ } catch (ex: IOException) {
+ // This shall never happen
+ throw RuntimeException(ex)
+ }
+ }
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.java b/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.java
deleted file mode 100644
index 7f14179..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.java
+++ /dev/null
@@ -1,175 +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 com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.os.Build;
-import android.os.Environment;
-import android.util.Log;
-
-import org.apache.commons.compress.archivers.ArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
-import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
-
-import java.io.BufferedInputStream;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.HttpURLConnection;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-import java.util.Arrays;
-import java.util.function.Function;
-
-/**
- * ImageArchive models the archive file (images.tar.gz) where VM payload files are in. This class
- * provides methods for handling the archive file, most importantly installing it.
- */
-class ImageArchive {
- private static final String DIR_IN_SDCARD = "linux";
- private static final String ARCHIVE_NAME = "images.tar.gz";
- private static final String BUILD_TAG = "latest"; // TODO: use actual tag name
- private static final String HOST_URL = "https://dl.google.com/android/ferrochrome/" + BUILD_TAG;
-
- // Only one can be non-null
- private final URL mUrl;
- private final Path mPath;
-
- private ImageArchive(URL url) {
- mUrl = url;
- mPath = null;
- }
-
- private ImageArchive(Path path) {
- mUrl = null;
- mPath = path;
- }
-
- public static Path getSdcardPathForTesting() {
- return Environment.getExternalStoragePublicDirectory(DIR_IN_SDCARD).toPath();
- }
-
- /** Creates ImageArchive which is located in the sdcard. This archive is for testing only. */
- public static ImageArchive fromSdCard() {
- Path file = getSdcardPathForTesting().resolve(ARCHIVE_NAME);
- return new ImageArchive(file);
- }
-
- /** Creates ImageArchive which is hosted in the Google server. This is the official archive. */
- public static ImageArchive fromInternet() {
- String arch = Arrays.asList(Build.SUPPORTED_ABIS).contains("x86_64") ? "x86_64" : "aarch64";
- try {
- URL url = new URL(HOST_URL + "/" + arch + "/" + ARCHIVE_NAME);
- return new ImageArchive(url);
- } catch (MalformedURLException e) {
- // cannot happen
- throw new RuntimeException(e);
- }
- }
-
- /**
- * Creates ImageArchive from either SdCard or Internet. SdCard is used only when the build is
- * debuggable and the file actually exists.
- */
- public static ImageArchive getDefault() {
- ImageArchive archive = fromSdCard();
- if (Build.isDebuggable() && archive.exists()) {
- return archive;
- } else {
- return fromInternet();
- }
- }
-
- /** Tests if ImageArchive exists on the medium. */
- public boolean exists() {
- if (mPath != null) {
- return Files.exists(mPath);
- } else {
- // TODO
- return true;
- }
- }
-
- /** Returns size of the archive in bytes */
- public long getSize() throws IOException {
- if (!exists()) {
- throw new IllegalStateException("Cannot get size of non existing archive");
- }
- if (mPath != null) {
- return Files.size(mPath);
- } else {
- HttpURLConnection conn = null;
- try {
- conn = (HttpURLConnection) mUrl.openConnection();
- conn.setRequestMethod("HEAD");
- conn.getInputStream();
- return conn.getContentLength();
- } finally {
- if (conn != null) {
- conn.disconnect();
- }
- }
- }
- }
-
- private InputStream getInputStream(Function<InputStream, InputStream> filter)
- throws IOException {
- InputStream is = mPath != null ? new FileInputStream(mPath.toFile()) : mUrl.openStream();
- BufferedInputStream bufStream = new BufferedInputStream(is);
- return filter == null ? bufStream : filter.apply(bufStream);
- }
-
- /**
- * Installs this ImageArchive to a directory pointed by path. filter can be supplied to provide
- * an additional input stream which will be used during the installation.
- */
- public void installTo(Path dir, Function<InputStream, InputStream> filter) throws IOException {
- String source = mPath != null ? mPath.toString() : mUrl.toString();
- Log.d(TAG, "Installing. source: " + source + ", destination: " + dir.toString());
- try (InputStream stream = getInputStream(filter);
- GzipCompressorInputStream gzStream = new GzipCompressorInputStream(stream);
- TarArchiveInputStream tarStream = new TarArchiveInputStream(gzStream)) {
-
- Files.createDirectories(dir);
- ArchiveEntry entry;
- while ((entry = tarStream.getNextEntry()) != null) {
- Path to = dir.resolve(entry.getName());
- if (Files.isDirectory(to)) {
- Files.createDirectories(to);
- continue;
- }
- Files.copy(tarStream, to, StandardCopyOption.REPLACE_EXISTING);
- }
- }
- commitInstallationAt(dir);
- }
-
- private void commitInstallationAt(Path dir) throws IOException {
- // To save storage, delete the source archive on the disk.
- if (mPath != null) {
- Files.deleteIfExists(mPath);
- }
-
- // Mark the completion
- Path marker = dir.resolve(InstalledImage.MARKER_FILENAME);
- Files.createFile(marker);
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.kt b/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.kt
new file mode 100644
index 0000000..31c9a91
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.os.Build
+import android.os.Environment
+import android.util.Log
+import com.android.virtualization.terminal.MainActivity.TAG
+import java.io.BufferedInputStream
+import java.io.FileInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.lang.RuntimeException
+import java.net.HttpURLConnection
+import java.net.MalformedURLException
+import java.net.URL
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardCopyOption
+import java.util.function.Function
+import org.apache.commons.compress.archivers.ArchiveEntry
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
+
+/**
+ * ImageArchive models the archive file (images.tar.gz) where VM payload files are in. This class
+ * provides methods for handling the archive file, most importantly installing it.
+ */
+internal class ImageArchive {
+ // Only one can be non-null
+ private sealed class Source<out A, out B>
+
+ private data class UrlSource<out Url>(val value: Url) : Source<Url, Nothing>()
+
+ private data class PathSource<out Path>(val value: Path) : Source<Nothing, Path>()
+
+ private val source: Source<URL, Path>
+
+ private constructor(url: URL) {
+ source = UrlSource(url)
+ }
+
+ private constructor(path: Path) {
+ source = PathSource(path)
+ }
+
+ /** Tests if ImageArchive exists on the medium. */
+ fun exists(): Boolean {
+ return when (source) {
+ is UrlSource -> true
+ is PathSource -> Files.exists(source.value)
+ }
+ }
+
+ /** Returns size of the archive in bytes */
+ @Throws(IOException::class)
+ fun getSize(): Long {
+ check(exists()) { "Cannot get size of non existing archive" }
+ return when (source) {
+ is UrlSource -> {
+ val conn = source.value.openConnection() as HttpURLConnection
+ try {
+ conn.requestMethod = "HEAD"
+ conn.getInputStream()
+ return conn.contentLength.toLong()
+ } finally {
+ conn.disconnect()
+ }
+ }
+ is PathSource -> Files.size(source.value)
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun getInputStream(filter: Function<InputStream, InputStream>?): InputStream? {
+ val bufStream =
+ BufferedInputStream(
+ when (source) {
+ is UrlSource -> source.value.openStream()
+ is PathSource -> FileInputStream(source.value.toFile())
+ }
+ )
+ return filter?.apply(bufStream) ?: bufStream
+ }
+
+ /**
+ * Installs this ImageArchive to a directory pointed by path. filter can be supplied to provide
+ * an additional input stream which will be used during the installation.
+ */
+ @Throws(IOException::class)
+ fun installTo(dir: Path, filter: Function<InputStream, InputStream>?) {
+ val source =
+ when (source) {
+ is PathSource -> source.value.toString()
+ is UrlSource -> source.value.toString()
+ }
+ Log.d(TAG, "Installing. source: $source, destination: $dir")
+ TarArchiveInputStream(GzipCompressorInputStream(getInputStream(filter))).use { tarStream ->
+ Files.createDirectories(dir)
+ var entry: ArchiveEntry?
+ while ((tarStream.nextEntry.also { entry = it }) != null) {
+ val to = dir.resolve(entry!!.getName())
+ if (Files.isDirectory(to)) {
+ Files.createDirectories(to)
+ continue
+ }
+ Files.copy(tarStream, to, StandardCopyOption.REPLACE_EXISTING)
+ }
+ }
+ commitInstallationAt(dir)
+ }
+
+ @Throws(IOException::class)
+ private fun commitInstallationAt(dir: Path) {
+ // To save storage, delete the source archive on the disk.
+ if (source is PathSource) {
+ Files.deleteIfExists(source.value)
+ }
+
+ // Mark the completion
+ val marker = dir.resolve(InstalledImage.MARKER_FILENAME)
+ Files.createFile(marker)
+ }
+
+ companion object {
+ private const val DIR_IN_SDCARD = "linux"
+ private const val ARCHIVE_NAME = "images.tar.gz"
+ private const val BUILD_TAG = "latest" // TODO: use actual tag name
+ private const val HOST_URL = "https://dl.google.com/android/ferrochrome/$BUILD_TAG"
+
+ @JvmStatic
+ fun getSdcardPathForTesting(): Path {
+ return Environment.getExternalStoragePublicDirectory(DIR_IN_SDCARD).toPath()
+ }
+
+ /**
+ * Creates ImageArchive which is located in the sdcard. This archive is for testing only.
+ */
+ @JvmStatic
+ fun fromSdCard(): ImageArchive {
+ return ImageArchive(getSdcardPathForTesting().resolve(ARCHIVE_NAME))
+ }
+
+ /**
+ * Creates ImageArchive which is hosted in the Google server. This is the official archive.
+ */
+ @JvmStatic
+ fun fromInternet(): ImageArchive {
+ val arch =
+ if (listOf<String?>(*Build.SUPPORTED_ABIS).contains("x86_64")) "x86_64"
+ else "aarch64"
+ try {
+ return ImageArchive(URL("$HOST_URL/$arch/$ARCHIVE_NAME"))
+ } catch (e: MalformedURLException) {
+ // cannot happen
+ throw RuntimeException(e)
+ }
+ }
+
+ /**
+ * Creates ImageArchive from either SdCard or Internet. SdCard is used only when the build
+ * is debuggable and the file actually exists.
+ */
+ @JvmStatic
+ fun getDefault(): ImageArchive {
+ val archive = fromSdCard()
+ return if (Build.isDebuggable() && archive.exists()) {
+ archive
+ } else {
+ fromInternet()
+ }
+ }
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.java
deleted file mode 100644
index 318f49a..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.java
+++ /dev/null
@@ -1,212 +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 com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.content.Context;
-import android.os.FileUtils;
-import android.system.ErrnoException;
-import android.system.Os;
-import android.util.Log;
-
-import java.io.BufferedReader;
-import java.io.FileDescriptor;
-import java.io.FileReader;
-import java.io.IOException;
-import java.io.RandomAccessFile;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-
-/** Collection of files that consist of a VM image. */
-class InstalledImage {
- private static final String INSTALL_DIRNAME = "linux";
- private static final String ROOTFS_FILENAME = "root_part";
- private static final String BACKUP_FILENAME = "root_part_backup";
- private static final String CONFIG_FILENAME = "vm_config.json";
- private static final String BUILD_ID_FILENAME = "build_id";
- static final String MARKER_FILENAME = "completed";
-
- public static final long RESIZE_STEP_BYTES = 4 << 20; // 4 MiB
-
- private final Path mDir;
- private final Path mRootPartition;
- private final Path mBackup;
- private final Path mConfig;
- private final Path mMarker;
- private String mBuildId;
-
- /** Returns InstalledImage for a given app context */
- public static InstalledImage getDefault(Context context) {
- Path installDir = context.getFilesDir().toPath().resolve(INSTALL_DIRNAME);
- return new InstalledImage(installDir);
- }
-
- private InstalledImage(Path dir) {
- mDir = dir;
- mRootPartition = dir.resolve(ROOTFS_FILENAME);
- mBackup = dir.resolve(BACKUP_FILENAME);
- mConfig = dir.resolve(CONFIG_FILENAME);
- mMarker = dir.resolve(MARKER_FILENAME);
- }
-
- public Path getInstallDir() {
- return mDir;
- }
-
- /** Tests if this InstalledImage is actually installed. */
- public boolean isInstalled() {
- return Files.exists(mMarker);
- }
-
- /** Fully understalls this InstalledImage by deleting everything. */
- public void uninstallFully() throws IOException {
- FileUtils.deleteContentsAndDir(mDir.toFile());
- }
-
- /** Returns the path to the VM config file. */
- public Path getConfigPath() {
- return mConfig;
- }
-
- /** Returns the build ID of the installed image */
- public String getBuildId() {
- if (mBuildId == null) {
- mBuildId = readBuildId();
- }
- return mBuildId;
- }
-
- private String readBuildId() {
- Path file = mDir.resolve(BUILD_ID_FILENAME);
- if (!Files.exists(file)) {
- return "<no build id>";
- }
- try (BufferedReader r = new BufferedReader(new FileReader(file.toFile()))) {
- return r.readLine();
- } catch (IOException e) {
- throw new RuntimeException("Failed to read build ID", e);
- }
- }
-
- public Path uninstallAndBackup() throws IOException {
- Files.delete(mMarker);
- Files.move(mRootPartition, mBackup, StandardCopyOption.REPLACE_EXISTING);
- return mBackup;
- }
-
- public Path getBackupFile() {
- return mBackup;
- }
-
- public boolean hasBackup() {
- return Files.exists(mBackup);
- }
-
- public void deleteBackup() throws IOException {
- Files.deleteIfExists(mBackup);
- }
-
- public long getSize() throws IOException {
- return Files.size(mRootPartition);
- }
-
- public long getSmallestSizePossible() throws IOException {
- runE2fsck(mRootPartition);
- String p = mRootPartition.toAbsolutePath().toString();
- String result = runCommand("/system/bin/resize2fs", "-P", p);
- // The return value is the number of 4k block
- try {
- long minSize =
- Long.parseLong(result.lines().toArray(String[]::new)[1].substring(42))
- * 4
- * 1024;
- return roundUp(minSize);
- } catch (NumberFormatException e) {
- Log.e(TAG, "Failed to parse min size, p=" + p + ", result=" + result);
- throw new IOException(e);
- }
- }
-
- public long resize(long desiredSize) throws IOException {
- desiredSize = roundUp(desiredSize);
- final long curSize = getSize();
-
- if (desiredSize == curSize) {
- return desiredSize;
- }
-
- runE2fsck(mRootPartition);
- if (desiredSize > curSize) {
- allocateSpace(mRootPartition, desiredSize);
- }
- resizeFilesystem(mRootPartition, desiredSize);
- return getSize();
- }
-
- private static void allocateSpace(Path path, long sizeInBytes) throws IOException {
- try {
- RandomAccessFile raf = new RandomAccessFile(path.toFile(), "rw");
- FileDescriptor fd = raf.getFD();
- Os.posix_fallocate(fd, 0, sizeInBytes);
- raf.close();
- Log.d(TAG, "Allocated space to: " + sizeInBytes + " bytes");
- } catch (ErrnoException e) {
- Log.e(TAG, "Failed to allocate space", e);
- throw new IOException("Failed to allocate space", e);
- }
- }
-
- private static void runE2fsck(Path path) throws IOException {
- String p = path.toAbsolutePath().toString();
- runCommand("/system/bin/e2fsck", "-y", "-f", p);
- Log.d(TAG, "e2fsck completed: " + path);
- }
-
- private static void resizeFilesystem(Path path, long sizeInBytes) throws IOException {
- long sizeInMB = sizeInBytes / (1024 * 1024);
- if (sizeInMB == 0) {
- Log.e(TAG, "Invalid size: " + sizeInBytes + " bytes");
- throw new IllegalArgumentException("Size cannot be zero MB");
- }
- String sizeArg = sizeInMB + "M";
- String p = path.toAbsolutePath().toString();
- runCommand("/system/bin/resize2fs", p, sizeArg);
- Log.d(TAG, "resize2fs completed: " + path + ", size: " + sizeArg);
- }
-
- private static String runCommand(String... command) throws IOException {
- try {
- Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
- process.waitFor();
- String result = new String(process.getInputStream().readAllBytes());
- if (process.exitValue() != 0) {
- Log.w(TAG, "Process returned with error, command=" + String.join(" ", command)
- + ", exitValue=" + process.exitValue() + ", result=" + result);
- }
- return result;
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- throw new IOException("Command interrupted", e);
- }
- }
-
- private static long roundUp(long bytes) {
- // Round up every diskSizeStep MB
- return (long) Math.ceil(((double) bytes) / RESIZE_STEP_BYTES) * RESIZE_STEP_BYTES;
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
new file mode 100644
index 0000000..e52f996
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
@@ -0,0 +1,200 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.os.FileUtils
+import android.system.ErrnoException
+import android.system.Os
+import android.util.Log
+import com.android.virtualization.terminal.MainActivity.TAG
+import java.io.BufferedReader
+import java.io.FileReader
+import java.io.IOException
+import java.io.RandomAccessFile
+import java.lang.IllegalArgumentException
+import java.lang.NumberFormatException
+import java.lang.RuntimeException
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardCopyOption
+import kotlin.math.ceil
+
+/** Collection of files that consist of a VM image. */
+internal class InstalledImage private constructor(val installDir: Path) {
+ private val rootPartition: Path = installDir.resolve(ROOTFS_FILENAME)
+ val backupFile: Path = installDir.resolve(BACKUP_FILENAME)
+
+ /** The path to the VM config file. */
+ val configPath: Path = installDir.resolve(CONFIG_FILENAME)
+ private val marker: Path = installDir.resolve(MARKER_FILENAME)
+ /** The build ID of the installed image */
+ val buildId: String by lazy { readBuildId() }
+
+ /** Tests if this InstalledImage is actually installed. */
+ fun isInstalled(): Boolean {
+ return Files.exists(marker)
+ }
+
+ /** Fully uninstall this InstalledImage by deleting everything. */
+ @Throws(IOException::class)
+ fun uninstallFully() {
+ FileUtils.deleteContentsAndDir(installDir.toFile())
+ }
+
+ private fun readBuildId(): String {
+ val file = installDir.resolve(BUILD_ID_FILENAME)
+ if (!Files.exists(file)) {
+ return "<no build id>"
+ }
+ try {
+ BufferedReader(FileReader(file.toFile())).use { r ->
+ return r.readLine()
+ }
+ } catch (e: IOException) {
+ throw RuntimeException("Failed to read build ID", e)
+ }
+ }
+
+ @Throws(IOException::class)
+ fun uninstallAndBackup(): Path {
+ Files.delete(marker)
+ Files.move(rootPartition, backupFile, StandardCopyOption.REPLACE_EXISTING)
+ return backupFile
+ }
+
+ fun hasBackup(): Boolean {
+ return Files.exists(backupFile)
+ }
+
+ @Throws(IOException::class)
+ fun deleteBackup() {
+ Files.deleteIfExists(backupFile)
+ }
+
+ @Throws(IOException::class)
+ fun getSize(): Long {
+ return Files.size(rootPartition)
+ }
+
+ @Throws(IOException::class)
+ fun getSmallestSizePossible(): Long {
+ runE2fsck(rootPartition)
+ val p: String = rootPartition.toAbsolutePath().toString()
+ val result = runCommand("/system/bin/resize2fs", "-P", p)
+ // The return value is the number of 4k block
+ return try {
+ roundUp(result.lines().first().substring(42).toLong() * 4 * 1024)
+ } catch (e: NumberFormatException) {
+ Log.e(TAG, "Failed to parse min size, p=$p, result=$result")
+ throw IOException(e)
+ }
+ }
+
+ @Throws(IOException::class)
+ fun resize(desiredSize: Long): Long {
+ val roundedUpDesiredSize = roundUp(desiredSize)
+ val curSize = getSize()
+
+ if (roundedUpDesiredSize == curSize) {
+ return roundedUpDesiredSize
+ }
+
+ runE2fsck(rootPartition)
+ if (roundedUpDesiredSize > curSize) {
+ allocateSpace(rootPartition, roundedUpDesiredSize)
+ }
+ resizeFilesystem(rootPartition, roundedUpDesiredSize)
+ return getSize()
+ }
+
+ companion object {
+ private const val INSTALL_DIRNAME = "linux"
+ private const val ROOTFS_FILENAME = "root_part"
+ private const val BACKUP_FILENAME = "root_part_backup"
+ private const val CONFIG_FILENAME = "vm_config.json"
+ private const val BUILD_ID_FILENAME = "build_id"
+ const val MARKER_FILENAME: String = "completed"
+
+ const val RESIZE_STEP_BYTES: Long = 4 shl 20 // 4 MiB
+
+ /** Returns InstalledImage for a given app context */
+ @JvmStatic
+ fun getDefault(context: Context): InstalledImage {
+ val installDir = context.getFilesDir().toPath().resolve(INSTALL_DIRNAME)
+ return InstalledImage(installDir)
+ }
+
+ @Throws(IOException::class)
+ private fun allocateSpace(path: Path, sizeInBytes: Long) {
+ try {
+ val raf = RandomAccessFile(path.toFile(), "rw")
+ val fd = raf.fd
+ Os.posix_fallocate(fd, 0, sizeInBytes)
+ raf.close()
+ Log.d(TAG, "Allocated space to: $sizeInBytes bytes")
+ } catch (e: ErrnoException) {
+ Log.e(TAG, "Failed to allocate space", e)
+ throw IOException("Failed to allocate space", e)
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun runE2fsck(path: Path) {
+ val p: String = path.toAbsolutePath().toString()
+ runCommand("/system/bin/e2fsck", "-y", "-f", p)
+ Log.d(TAG, "e2fsck completed: $path")
+ }
+
+ @Throws(IOException::class)
+ private fun resizeFilesystem(path: Path, sizeInBytes: Long) {
+ val sizeInMB = sizeInBytes / (1024 * 1024)
+ if (sizeInMB == 0L) {
+ Log.e(TAG, "Invalid size: $sizeInBytes bytes")
+ throw IllegalArgumentException("Size cannot be zero MB")
+ }
+ val sizeArg = sizeInMB.toString() + "M"
+ val p: String = path.toAbsolutePath().toString()
+ runCommand("/system/bin/resize2fs", p, sizeArg)
+ Log.d(TAG, "resize2fs completed: $path, size: $sizeArg")
+ }
+
+ @Throws(IOException::class)
+ private fun runCommand(vararg command: String): String {
+ try {
+ val process = ProcessBuilder(*command).redirectErrorStream(true).start()
+ process.waitFor()
+ val result = String(process.inputStream.readAllBytes())
+ if (process.exitValue() != 0) {
+ Log.w(
+ TAG,
+ "Process returned with error, command=${listOf(*command).joinToString(" ")}," +
+ "exitValue=${process.exitValue()}, result=$result",
+ )
+ }
+ return result
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw IOException("Command interrupted", e)
+ }
+ }
+
+ private fun roundUp(bytes: Long): Long {
+ // Round up every diskSizeStep MB
+ return ceil((bytes.toDouble()) / RESIZE_STEP_BYTES).toLong() * RESIZE_STEP_BYTES
+ }
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/Logger.java b/android/TerminalApp/java/com/android/virtualization/terminal/Logger.java
deleted file mode 100644
index 2c0149e..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/Logger.java
+++ /dev/null
@@ -1,87 +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 android.system.virtualmachine.VirtualMachine;
-import android.system.virtualmachine.VirtualMachineConfig;
-import android.system.virtualmachine.VirtualMachineException;
-import android.util.Log;
-
-import libcore.io.Streams;
-
-import java.io.BufferedOutputStream;
-import java.io.BufferedReader;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardOpenOption;
-import java.util.concurrent.ExecutorService;
-
-/**
- * Forwards VM's console output to a file on the Android side, and VM's log output to Android logd.
- */
-class Logger {
- private Logger() {}
-
- static void setup(VirtualMachine vm, Path path, ExecutorService executor) {
- if (vm.getConfig().getDebugLevel() != VirtualMachineConfig.DEBUG_LEVEL_FULL) {
- return;
- }
-
- try {
- InputStream console = vm.getConsoleOutput();
- OutputStream file = Files.newOutputStream(path, StandardOpenOption.CREATE);
- executor.submit(() -> Streams.copy(console, new LineBufferedOutputStream(file)));
-
- InputStream log = vm.getLogOutput();
- executor.submit(() -> writeToLogd(log, vm.getName()));
- } catch (VirtualMachineException | IOException e) {
- throw new RuntimeException(e);
- }
- }
-
- private static boolean writeToLogd(InputStream input, String vmName) throws IOException {
- BufferedReader reader = new BufferedReader(new InputStreamReader(input));
- String line;
- while ((line = reader.readLine()) != null && !Thread.interrupted()) {
- Log.d(vmName, line);
- }
- // TODO: find out why javac complains when the return type of this method is void. It
- // (incorrectly?) thinks that IOException should be caught inside the lambda.
- return true;
- }
-
- private static class LineBufferedOutputStream extends BufferedOutputStream {
- LineBufferedOutputStream(OutputStream out) {
- super(out);
- }
-
- @Override
- public void write(byte[] buf, int off, int len) throws IOException {
- super.write(buf, off, len);
- for (int i = 0; i < len; ++i) {
- if (buf[off + i] == '\n') {
- flush();
- break;
- }
- }
- }
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/Logger.kt b/android/TerminalApp/java/com/android/virtualization/terminal/Logger.kt
new file mode 100644
index 0000000..3a273ec
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/Logger.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.system.virtualmachine.VirtualMachine
+import android.system.virtualmachine.VirtualMachineConfig
+import android.system.virtualmachine.VirtualMachineException
+import android.util.Log
+import com.android.virtualization.terminal.Logger.LineBufferedOutputStream
+import java.io.BufferedOutputStream
+import java.io.BufferedReader
+import java.io.IOException
+import java.io.InputStream
+import java.io.InputStreamReader
+import java.io.OutputStream
+import java.lang.RuntimeException
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardOpenOption
+import java.util.concurrent.ExecutorService
+import libcore.io.Streams
+
+/**
+ * Forwards VM's console output to a file on the Android side, and VM's log output to Android logd.
+ */
+internal object Logger {
+ @JvmStatic
+ fun setup(vm: VirtualMachine, path: Path, executor: ExecutorService) {
+ if (vm.config.debugLevel != VirtualMachineConfig.DEBUG_LEVEL_FULL) {
+ return
+ }
+
+ try {
+ val console = vm.getConsoleOutput()
+ val file = Files.newOutputStream(path, StandardOpenOption.CREATE)
+ executor.submit<Int?> {
+ console.use { console ->
+ LineBufferedOutputStream(file).use { fileOutput ->
+ Streams.copy(console, fileOutput)
+ }
+ }
+ }
+
+ val log = vm.getLogOutput()
+ executor.submit<Unit> { log.use { writeToLogd(it, vm.name) } }
+ } catch (e: VirtualMachineException) {
+ throw RuntimeException(e)
+ } catch (e: IOException) {
+ throw RuntimeException(e)
+ }
+ }
+
+ @Throws(IOException::class)
+ private fun writeToLogd(input: InputStream?, vmName: String?) {
+ val reader = BufferedReader(InputStreamReader(input))
+ reader
+ .useLines { lines -> lines.takeWhile { !Thread.interrupted() } }
+ .forEach { Log.d(vmName, it) }
+ }
+
+ private class LineBufferedOutputStream(out: OutputStream?) : BufferedOutputStream(out) {
+ @Throws(IOException::class)
+ override fun write(buf: ByteArray, off: Int, len: Int) {
+ super.write(buf, off, len)
+ (0 until len).firstOrNull { buf[off + it] == '\n'.code.toByte() }?.let { flush() }
+ }
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.java b/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.java
deleted file mode 100644
index 0d70ab9..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.java
+++ /dev/null
@@ -1,146 +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 com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.app.Notification;
-import android.app.Notification.Action;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.graphics.drawable.Icon;
-
-import java.util.HashSet;
-import java.util.Locale;
-import java.util.Set;
-
-/**
- * PortNotifier is responsible for posting a notification when a new open port is detected. User can
- * enable or disable forwarding of the port in notification panel.
- */
-class PortNotifier {
- private static final String ACTION_PORT_FORWARDING = "android.virtualization.PORT_FORWARDING";
- private static final String KEY_PORT = "port";
- private static final String KEY_ENABLED = "enabled";
-
- private final Context mContext;
- private final NotificationManager mNotificationManager;
- private final BroadcastReceiver mReceiver;
- private final PortsStateManager mPortsStateManager;
- private final PortsStateManager.Listener mPortsStateListener;
-
- public PortNotifier(Context context) {
- mContext = context;
- mNotificationManager = mContext.getSystemService(NotificationManager.class);
- mReceiver = new PortForwardingRequestReceiver();
-
- mPortsStateManager = PortsStateManager.getInstance(mContext);
- mPortsStateListener =
- new PortsStateManager.Listener() {
- @Override
- public void onPortsStateUpdated(
- Set<Integer> oldActivePorts, Set<Integer> newActivePorts) {
- Set<Integer> union = new HashSet<>(oldActivePorts);
- union.addAll(newActivePorts);
- for (int port : union) {
- if (!oldActivePorts.contains(port)) {
- showNotificationFor(port);
- } else if (!newActivePorts.contains(port)) {
- discardNotificationFor(port);
- }
- }
- }
- };
- mPortsStateManager.registerListener(mPortsStateListener);
-
- IntentFilter intentFilter = new IntentFilter(ACTION_PORT_FORWARDING);
- mContext.registerReceiver(mReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED);
- }
-
- public void stop() {
- mPortsStateManager.unregisterListener(mPortsStateListener);
- mContext.unregisterReceiver(mReceiver);
- }
-
- private String getString(int resId) {
- return mContext.getString(resId);
- }
-
- private PendingIntent getPendingIntentFor(int port, boolean enabled) {
- Intent intent = new Intent(ACTION_PORT_FORWARDING);
- intent.setPackage(mContext.getPackageName());
- intent.setIdentifier(String.format(Locale.ROOT, "%d_%b", port, enabled));
- intent.putExtra(KEY_PORT, port);
- intent.putExtra(KEY_ENABLED, enabled);
- return PendingIntent.getBroadcast(mContext, 0, intent, PendingIntent.FLAG_IMMUTABLE);
- }
-
- private void showNotificationFor(int port) {
- Intent tapIntent = new Intent(mContext, SettingsPortForwardingActivity.class);
- tapIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
- PendingIntent tapPendingIntent =
- PendingIntent.getActivity(mContext, 0, tapIntent, PendingIntent.FLAG_IMMUTABLE);
-
- String title = getString(R.string.settings_port_forwarding_notification_title);
- String content =
- mContext.getString(R.string.settings_port_forwarding_notification_content, port);
- String acceptText = getString(R.string.settings_port_forwarding_notification_accept);
- String denyText = getString(R.string.settings_port_forwarding_notification_deny);
- Icon icon = Icon.createWithResource(mContext, R.drawable.ic_launcher_foreground);
-
- Action acceptAction =
- new Action.Builder(icon, acceptText, getPendingIntentFor(port, true /* enabled */))
- .build();
- Action denyAction =
- new Action.Builder(icon, denyText, getPendingIntentFor(port, false /* enabled */))
- .build();
- Notification notification =
- new Notification.Builder(mContext, mContext.getPackageName())
- .setSmallIcon(R.drawable.ic_launcher_foreground)
- .setContentTitle(title)
- .setContentText(content)
- .setContentIntent(tapPendingIntent)
- .addAction(acceptAction)
- .addAction(denyAction)
- .build();
- mNotificationManager.notify(TAG, port, notification);
- }
-
- private void discardNotificationFor(int port) {
- mNotificationManager.cancel(TAG, port);
- }
-
- private final class PortForwardingRequestReceiver extends BroadcastReceiver {
- @Override
- public void onReceive(Context context, Intent intent) {
- if (ACTION_PORT_FORWARDING.equals(intent.getAction())) {
- performActionPortForwarding(context, intent);
- }
- }
-
- private void performActionPortForwarding(Context context, Intent intent) {
- int port = intent.getIntExtra(KEY_PORT, 0);
- boolean enabled = intent.getBooleanExtra(KEY_ENABLED, false);
- mPortsStateManager.updateEnabledPort(port, enabled);
- discardNotificationFor(port);
- }
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt b/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt
new file mode 100644
index 0000000..ed6e02e
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt
@@ -0,0 +1,139 @@
+/*
+ * 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.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.drawable.Icon
+import java.util.HashSet
+import java.util.Locale
+
+/**
+ * PortNotifier is responsible for posting a notification when a new open port is detected. User can
+ * enable or disable forwarding of the port in notification panel.
+ */
+internal class PortNotifier(val context: Context) {
+ private val notificationManager: NotificationManager =
+ context.getSystemService<NotificationManager?>(NotificationManager::class.java)
+ private val receiver: BroadcastReceiver =
+ PortForwardingRequestReceiver().also {
+ val intentFilter = IntentFilter(ACTION_PORT_FORWARDING)
+ context.registerReceiver(it, intentFilter, Context.RECEIVER_NOT_EXPORTED)
+ }
+ private val portsStateListener: PortsStateManager.Listener =
+ object : PortsStateManager.Listener {
+ override fun onPortsStateUpdated(oldActivePorts: Set<Int>, newActivePorts: Set<Int>) {
+ val union: MutableSet<Int> = HashSet<Int>(oldActivePorts)
+ union.addAll(newActivePorts)
+ for (port in union) {
+ if (!oldActivePorts.contains(port)) {
+ showNotificationFor(port)
+ } else if (!newActivePorts.contains(port)) {
+ discardNotificationFor(port)
+ }
+ }
+ }
+ }
+ private val portsStateManager: PortsStateManager =
+ PortsStateManager.getInstance(context).also { it.registerListener(portsStateListener) }
+
+ fun stop() {
+ portsStateManager.unregisterListener(portsStateListener)
+ context.unregisterReceiver(receiver)
+ }
+
+ private fun getString(resId: Int): String {
+ return context.getString(resId)
+ }
+
+ private fun getPendingIntentFor(port: Int, enabled: Boolean): PendingIntent? {
+ val intent = Intent(ACTION_PORT_FORWARDING)
+ intent.setPackage(context.getPackageName())
+ intent.setIdentifier(String.format(Locale.ROOT, "%d_%b", port, enabled))
+ intent.putExtra(KEY_PORT, port)
+ intent.putExtra(KEY_ENABLED, enabled)
+ return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
+ }
+
+ private fun showNotificationFor(port: Int) {
+ val tapIntent = Intent(context, SettingsPortForwardingActivity::class.java)
+ tapIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ val tapPendingIntent =
+ PendingIntent.getActivity(context, 0, tapIntent, PendingIntent.FLAG_IMMUTABLE)
+
+ val title = getString(R.string.settings_port_forwarding_notification_title)
+ val content =
+ context.getString(R.string.settings_port_forwarding_notification_content, port)
+ val acceptText = getString(R.string.settings_port_forwarding_notification_accept)
+ val denyText = getString(R.string.settings_port_forwarding_notification_deny)
+ val icon = Icon.createWithResource(context, R.drawable.ic_launcher_foreground)
+
+ val acceptAction: Notification.Action =
+ Notification.Action.Builder(
+ icon,
+ acceptText,
+ getPendingIntentFor(port, true /* enabled */),
+ )
+ .build()
+ val denyAction: Notification.Action =
+ Notification.Action.Builder(
+ icon,
+ denyText,
+ getPendingIntentFor(port, false /* enabled */),
+ )
+ .build()
+ val notification: Notification =
+ Notification.Builder(context, context.getPackageName())
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(title)
+ .setContentText(content)
+ .setContentIntent(tapPendingIntent)
+ .addAction(acceptAction)
+ .addAction(denyAction)
+ .build()
+ notificationManager.notify(MainActivity.TAG, port, notification)
+ }
+
+ private fun discardNotificationFor(port: Int) {
+ notificationManager.cancel(MainActivity.TAG, port)
+ }
+
+ private inner class PortForwardingRequestReceiver : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent) {
+ if (ACTION_PORT_FORWARDING == intent.action) {
+ performActionPortForwarding(intent)
+ }
+ }
+
+ fun performActionPortForwarding(intent: Intent) {
+ val port = intent.getIntExtra(KEY_PORT, 0)
+ val enabled = intent.getBooleanExtra(KEY_ENABLED, false)
+ portsStateManager.updateEnabledPort(port, enabled)
+ discardNotificationFor(port)
+ }
+ }
+
+ companion object {
+ private const val ACTION_PORT_FORWARDING = "android.virtualization.PORT_FORWARDING"
+ private const val KEY_PORT = "port"
+ private const val KEY_ENABLED = "enabled"
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.java b/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.java
deleted file mode 100644
index 5321d89..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.java
+++ /dev/null
@@ -1,158 +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 android.content.Context;
-import android.content.SharedPreferences;
-
-import com.android.internal.annotations.GuardedBy;
-
-import java.util.HashSet;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-/**
- * PortsStateManager is responsible for communicating with shared preferences and managing state of
- * ports.
- */
-public class PortsStateManager {
- private static final String PREFS_NAME = ".PORTS";
- private static final int FLAG_ENABLED = 1;
-
- private static PortsStateManager mInstance;
- private final Object mLock = new Object();
-
- private final SharedPreferences mSharedPref;
-
- @GuardedBy("mLock")
- private Set<Integer> mActivePorts;
-
- @GuardedBy("mLock")
- private final Set<Integer> mEnabledPorts;
-
- @GuardedBy("mLock")
- private final Set<Listener> mListeners;
-
- private PortsStateManager(SharedPreferences sharedPref) {
- mSharedPref = sharedPref;
- mEnabledPorts =
- mSharedPref.getAll().entrySet().stream()
- .filter(entry -> entry.getValue() instanceof Integer)
- .filter(entry -> ((int) entry.getValue() & FLAG_ENABLED) == FLAG_ENABLED)
- .map(entry -> entry.getKey())
- .filter(
- key -> {
- try {
- Integer.parseInt(key);
- return true;
- } catch (NumberFormatException e) {
- return false;
- }
- })
- .map(Integer::parseInt)
- .collect(Collectors.toSet());
- mActivePorts = new HashSet<>();
- mListeners = new HashSet<>();
- }
-
- static synchronized PortsStateManager getInstance(Context context) {
- if (mInstance == null) {
- SharedPreferences sharedPref =
- context.getSharedPreferences(
- context.getPackageName() + PREFS_NAME, Context.MODE_PRIVATE);
- mInstance = new PortsStateManager(sharedPref);
- }
- return mInstance;
- }
-
- Set<Integer> getActivePorts() {
- synchronized (mLock) {
- return new HashSet<>(mActivePorts);
- }
- }
-
- Set<Integer> getEnabledPorts() {
- synchronized (mLock) {
- return new HashSet<>(mEnabledPorts);
- }
- }
-
- void updateActivePorts(Set<Integer> ports) {
- Set<Integer> oldPorts;
- synchronized (mLock) {
- oldPorts = mActivePorts;
- mActivePorts = ports;
- }
- notifyPortsStateUpdated(oldPorts, ports);
- }
-
- void updateEnabledPort(int port, boolean enabled) {
- Set<Integer> activePorts;
- synchronized (mLock) {
- SharedPreferences.Editor editor = mSharedPref.edit();
- editor.putInt(String.valueOf(port), enabled ? FLAG_ENABLED : 0);
- editor.apply();
- if (enabled) {
- mEnabledPorts.add(port);
- } else {
- mEnabledPorts.remove(port);
- }
- activePorts = mActivePorts;
- }
- notifyPortsStateUpdated(activePorts, activePorts);
- }
-
- void clearEnabledPorts() {
- Set<Integer> activePorts;
- synchronized (mLock) {
- SharedPreferences.Editor editor = mSharedPref.edit();
- editor.clear();
- editor.apply();
- mEnabledPorts.clear();
- activePorts = mActivePorts;
- }
- notifyPortsStateUpdated(activePorts, activePorts);
- }
-
- void registerListener(Listener listener) {
- synchronized (mLock) {
- mListeners.add(listener);
- }
- }
-
- void unregisterListener(Listener listener) {
- synchronized (mLock) {
- mListeners.remove(listener);
- }
- }
-
- private void notifyPortsStateUpdated(Set<Integer> oldActivePorts, Set<Integer> newActivePorts) {
- Set<Listener> listeners;
- synchronized (mLock) {
- listeners = new HashSet<>(mListeners);
- }
- for (Listener listener : listeners) {
- listener.onPortsStateUpdated(
- new HashSet<>(oldActivePorts), new HashSet<>(newActivePorts));
- }
- }
-
- interface Listener {
- default void onPortsStateUpdated(
- Set<Integer> oldActivePorts, Set<Integer> newActivePorts) {}
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.kt b/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.kt
new file mode 100644
index 0000000..736176a
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.content.SharedPreferences
+import com.android.internal.annotations.GuardedBy
+import java.util.HashSet
+
+/**
+ * PortsStateManager is responsible for communicating with shared preferences and managing state of
+ * ports.
+ */
+class PortsStateManager private constructor(private val sharedPref: SharedPreferences) {
+ private val lock = Any()
+
+ @GuardedBy("lock") private var activePorts: MutableSet<Int> = hashSetOf()
+
+ @GuardedBy("lock")
+ private val enabledPorts: MutableSet<Int> =
+ sharedPref
+ .getAll()
+ .entries
+ .filterIsInstance<MutableMap.MutableEntry<String, Int>>()
+ .filter { it.value and FLAG_ENABLED == FLAG_ENABLED }
+ .map { it.key.toIntOrNull() }
+ .filterNotNull()
+ .toMutableSet()
+
+ @GuardedBy("lock") private val listeners: MutableSet<Listener> = hashSetOf()
+
+ fun getActivePorts(): MutableSet<Int> {
+ synchronized(lock) {
+ return HashSet<Int>(activePorts)
+ }
+ }
+
+ fun getEnabledPorts(): MutableSet<Int> {
+ synchronized(lock) {
+ return HashSet<Int>(enabledPorts)
+ }
+ }
+
+ fun updateActivePorts(ports: MutableSet<Int>) {
+ var oldPorts: MutableSet<Int>
+ synchronized(lock) {
+ oldPorts = activePorts
+ activePorts = ports
+ }
+ notifyPortsStateUpdated(oldPorts, ports)
+ }
+
+ fun updateEnabledPort(port: Int, enabled: Boolean) {
+ var activePorts: MutableSet<Int>
+ synchronized(lock) {
+ val editor = sharedPref.edit()
+ editor.putInt(port.toString(), if (enabled) FLAG_ENABLED else 0)
+ editor.apply()
+ if (enabled) {
+ enabledPorts.add(port)
+ } else {
+ enabledPorts.remove(port)
+ }
+ activePorts = this@PortsStateManager.activePorts
+ }
+ notifyPortsStateUpdated(activePorts, activePorts)
+ }
+
+ fun clearEnabledPorts() {
+ var activePorts: MutableSet<Int>
+ synchronized(lock) {
+ val editor = sharedPref.edit()
+ editor.clear()
+ editor.apply()
+ enabledPorts.clear()
+ activePorts = this@PortsStateManager.activePorts
+ }
+ notifyPortsStateUpdated(activePorts, activePorts)
+ }
+
+ fun registerListener(listener: Listener) {
+ synchronized(lock) { listeners.add(listener) }
+ }
+
+ fun unregisterListener(listener: Listener) {
+ synchronized(lock) { listeners.remove(listener) }
+ }
+
+ private fun notifyPortsStateUpdated(
+ oldActivePorts: MutableSet<Int>,
+ newActivePorts: MutableSet<Int>,
+ ) {
+ var listeners: MutableSet<Listener>
+ synchronized(lock) { listeners = HashSet<Listener>(this@PortsStateManager.listeners) }
+ for (listener in listeners) {
+ listener.onPortsStateUpdated(HashSet<Int>(oldActivePorts), HashSet<Int>(newActivePorts))
+ }
+ }
+
+ interface Listener {
+ fun onPortsStateUpdated(oldActivePorts: Set<Int>, newActivePorts: Set<Int>) {}
+ }
+
+ companion object {
+ private const val PREFS_NAME = ".PORTS"
+ private const val FLAG_ENABLED = 1
+
+ private var instance: PortsStateManager? = null
+
+ @JvmStatic
+ @Synchronized
+ fun getInstance(context: Context): PortsStateManager {
+ if (instance == null) {
+ val sharedPref =
+ context.getSharedPreferences(
+ context.getPackageName() + PREFS_NAME,
+ Context.MODE_PRIVATE,
+ )
+ instance = PortsStateManager(sharedPref)
+ }
+ return instance!!
+ }
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/Runner.java b/android/TerminalApp/java/com/android/virtualization/terminal/Runner.java
deleted file mode 100644
index 4094025..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/Runner.java
+++ /dev/null
@@ -1,115 +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 com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.content.Context;
-import android.system.virtualmachine.VirtualMachine;
-import android.system.virtualmachine.VirtualMachineCallback;
-import android.system.virtualmachine.VirtualMachineConfig;
-import android.system.virtualmachine.VirtualMachineCustomImageConfig;
-import android.system.virtualmachine.VirtualMachineException;
-import android.system.virtualmachine.VirtualMachineManager;
-import android.util.Log;
-
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.ForkJoinPool;
-
-/** Utility class for creating a VM and waiting for it to finish. */
-class Runner {
- private final VirtualMachine mVirtualMachine;
- private final Callback mCallback;
-
- private Runner(VirtualMachine vm, Callback cb) {
- mVirtualMachine = vm;
- mCallback = cb;
- }
-
- /** Create a virtual machine of the given config, under the given context. */
- static Runner create(Context context, VirtualMachineConfig config)
- throws VirtualMachineException {
- // context may already be the app context, but calling this again is not harmful.
- // See b/359439878 on why vmm should be obtained from the app context.
- Context appContext = context.getApplicationContext();
- VirtualMachineManager vmm = appContext.getSystemService(VirtualMachineManager.class);
- VirtualMachineCustomImageConfig customConfig = config.getCustomImageConfig();
- if (customConfig == null) {
- throw new RuntimeException("CustomImageConfig is missing");
- }
-
- String name = customConfig.getName();
- if (name == null || name.isEmpty()) {
- throw new RuntimeException("Virtual machine's name is missing in the config");
- }
-
- VirtualMachine vm = vmm.getOrCreate(name, config);
- try {
- vm.setConfig(config);
- } catch (VirtualMachineException e) {
- vmm.delete(name);
- vm = vmm.create(name, config);
- Log.w(TAG, "Re-creating virtual machine (" + name + ")", e);
- }
-
- Callback cb = new Callback();
- vm.setCallback(ForkJoinPool.commonPool(), cb);
- vm.run();
- return new Runner(vm, cb);
- }
-
- /** Give access to the underlying VirtualMachine object. */
- VirtualMachine getVm() {
- return mVirtualMachine;
- }
-
- /** Get future about VM's exit status. */
- CompletableFuture<Boolean> getExitStatus() {
- return mCallback.mFinishedSuccessfully;
- }
-
- private static class Callback implements VirtualMachineCallback {
- final CompletableFuture<Boolean> mFinishedSuccessfully = new CompletableFuture<>();
-
- @Override
- public void onPayloadStarted(VirtualMachine vm) {
- // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
- }
-
- @Override
- public void onPayloadReady(VirtualMachine vm) {
- // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
- }
-
- @Override
- public void onPayloadFinished(VirtualMachine vm, int exitCode) {
- // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
- }
-
- @Override
- public void onError(VirtualMachine vm, int errorCode, String message) {
- Log.e(TAG, "Error from VM. code: " + errorCode + " (" + message + ")");
- mFinishedSuccessfully.complete(false);
- }
-
- @Override
- public void onStopped(VirtualMachine vm, int reason) {
- Log.d(TAG, "VM stopped. Reason: " + reason);
- mFinishedSuccessfully.complete(true);
- }
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/Runner.kt b/android/TerminalApp/java/com/android/virtualization/terminal/Runner.kt
new file mode 100644
index 0000000..897e182
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/Runner.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.system.virtualmachine.VirtualMachine
+import android.system.virtualmachine.VirtualMachineCallback
+import android.system.virtualmachine.VirtualMachineConfig
+import android.system.virtualmachine.VirtualMachineException
+import android.system.virtualmachine.VirtualMachineManager
+import android.util.Log
+import com.android.virtualization.terminal.MainActivity.TAG
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ForkJoinPool
+
+/** Utility class for creating a VM and waiting for it to finish. */
+internal class Runner private constructor(val vm: VirtualMachine?, callback: Callback) {
+ /** Get future about VM's exit status. */
+ val exitStatus = callback.finishedSuccessfully
+
+ private class Callback : VirtualMachineCallback {
+ val finishedSuccessfully: CompletableFuture<Boolean> = CompletableFuture<Boolean>()
+
+ override fun onPayloadStarted(vm: VirtualMachine) {
+ // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+ }
+
+ override fun onPayloadReady(vm: VirtualMachine) {
+ // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+ }
+
+ override fun onPayloadFinished(vm: VirtualMachine, exitCode: Int) {
+ // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+ }
+
+ override fun onError(vm: VirtualMachine, errorCode: Int, message: String) {
+ Log.e(TAG, "Error from VM. code: $errorCode ($message)")
+ finishedSuccessfully.complete(false)
+ }
+
+ override fun onStopped(vm: VirtualMachine, reason: Int) {
+ Log.d(TAG, "VM stopped. Reason: $reason")
+ finishedSuccessfully.complete(true)
+ }
+ }
+
+ companion object {
+ /** Create a virtual machine of the given config, under the given context. */
+ @JvmStatic
+ @Throws(VirtualMachineException::class)
+ fun create(context: Context, config: VirtualMachineConfig): Runner {
+ // context may already be the app context, but calling this again is not harmful.
+ // See b/359439878 on why vmm should be obtained from the app context.
+ val appContext = context.getApplicationContext()
+ val vmm =
+ appContext.getSystemService<VirtualMachineManager>(
+ VirtualMachineManager::class.java
+ )
+ val customConfig = config.customImageConfig
+ requireNotNull(customConfig) { "CustomImageConfig is missing" }
+
+ val name = customConfig.name
+ require(!name.isNullOrEmpty()) { "Virtual machine's name is missing in the config" }
+
+ var vm = vmm.getOrCreate(name, config)
+ try {
+ vm.config = config
+ } catch (e: VirtualMachineException) {
+ vmm.delete(name)
+ vm = vmm.create(name, config)
+ Log.w(TAG, "Re-creating virtual machine ($name)", e)
+ }
+
+ val cb = Callback()
+ vm.setCallback(ForkJoinPool.commonPool(), cb)
+ vm.run()
+ return Runner(vm, cb)
+ }
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.java b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.java
deleted file mode 100644
index 4ab2b77..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.virtualization.terminal;
-
-import android.content.Context;
-import android.util.Log;
-
-public class TerminalExceptionHandler implements Thread.UncaughtExceptionHandler {
- private static final String TAG = "TerminalExceptionHandler";
-
- private final Context mContext;
-
- public TerminalExceptionHandler(Context context) {
- mContext = context;
- }
-
- @Override
- public void uncaughtException(Thread thread, Throwable throwable) {
- Exception exception;
- if (throwable instanceof Exception) {
- exception = (Exception) throwable;
- } else {
- exception = new Exception(throwable);
- }
- try {
- ErrorActivity.start(mContext, exception);
- } catch (Exception ex) {
- Log.wtf(TAG, "Failed to launch error activity for an exception", exception);
- }
-
- thread.getDefaultUncaughtExceptionHandler().uncaughtException(thread, throwable);
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.kt
new file mode 100644
index 0000000..3a8c444
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.util.Log
+import java.lang.Exception
+
+class TerminalExceptionHandler(private val context: Context) : Thread.UncaughtExceptionHandler {
+
+ override fun uncaughtException(thread: Thread, throwable: Throwable) {
+ val exception = (throwable as? Exception) ?: Exception(throwable)
+ try {
+ ErrorActivity.start(context, exception)
+ } catch (_: Exception) {
+ Log.wtf(TAG, "Failed to launch error activity for an exception", exception)
+ }
+ Thread.getDefaultUncaughtExceptionHandler()?.uncaughtException(thread, throwable)
+ }
+
+ companion object {
+ private const val TAG = "TerminalExceptionHandler"
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.java b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.java
deleted file mode 100644
index 5ee535d..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * Copyright 2024 The Android Open Source Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.android.virtualization.terminal;
-
-import android.content.Context;
-
-import java.util.concurrent.Executors;
-import java.util.concurrent.ThreadFactory;
-
-public class TerminalThreadFactory implements ThreadFactory {
- private final Context mContext;
-
- public TerminalThreadFactory(Context context) {
- mContext = context;
- }
-
- @Override
- public Thread newThread(Runnable r) {
- Thread thread = Executors.defaultThreadFactory().newThread(r);
- thread.setUncaughtExceptionHandler(new TerminalExceptionHandler(mContext));
- return thread;
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.kt
new file mode 100644
index 0000000..f8e909d
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import java.util.concurrent.Executors
+import java.util.concurrent.ThreadFactory
+
+class TerminalThreadFactory(private val context: Context) : ThreadFactory {
+ override fun newThread(r: Runnable): Thread {
+ return Executors.defaultThreadFactory().newThread(r).also {
+ it.uncaughtExceptionHandler = TerminalExceptionHandler(context)
+ }
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
deleted file mode 100644
index 0ffc093..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
+++ /dev/null
@@ -1,313 +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 com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.content.Context;
-import android.graphics.Rect;
-import android.os.Bundle;
-import android.text.InputType;
-import android.text.TextUtils;
-import android.util.AttributeSet;
-import android.util.Log;
-import android.view.View;
-import android.view.View.AccessibilityDelegate;
-import android.view.ViewGroup;
-import android.view.accessibility.AccessibilityEvent;
-import android.view.accessibility.AccessibilityManager;
-import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener;
-import android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener;
-import android.view.accessibility.AccessibilityNodeInfo;
-import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
-import android.view.accessibility.AccessibilityNodeProvider;
-import android.view.inputmethod.EditorInfo;
-import android.view.inputmethod.InputConnection;
-import android.webkit.WebView;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.List;
-
-public class TerminalView extends WebView
- implements AccessibilityStateChangeListener, TouchExplorationStateChangeListener {
- // Maximum length of texts the talk back announcements can be. This value is somewhat
- // arbitrarily set. We may want to adjust this in the future.
- private static final int TEXT_TOO_LONG_TO_ANNOUNCE = 200;
-
- private final String CTRL_KEY_HANDLER;
- private final String ENABLE_CTRL_KEY;
- private final String TOUCH_TO_MOUSE_HANDLER;
-
- private final AccessibilityManager mA11yManager;
-
- public TerminalView(Context context, AttributeSet attrs) {
- super(context, attrs);
-
- mA11yManager = context.getSystemService(AccessibilityManager.class);
- mA11yManager.addTouchExplorationStateChangeListener(this);
- mA11yManager.addAccessibilityStateChangeListener(this);
- adjustToA11yStateChange();
- try {
- CTRL_KEY_HANDLER = readAssetAsString(context, "js/ctrl_key_handler.js");
- ENABLE_CTRL_KEY = readAssetAsString(context, "js/enable_ctrl_key.js");
- TOUCH_TO_MOUSE_HANDLER = readAssetAsString(context, "js/touch_to_mouse_handler.js");
- } catch (IOException e) {
- // It cannot happen
- throw new IllegalArgumentException("cannot read code from asset", e);
- }
- }
-
- private String readAssetAsString(Context context, String filePath) throws IOException {
- try (InputStream is = context.getAssets().open(filePath)) {
- return new String(is.readAllBytes());
- }
- }
-
- public void mapTouchToMouseEvent() {
- this.evaluateJavascript(TOUCH_TO_MOUSE_HANDLER, null);
- }
-
- public void mapCtrlKey() {
- this.evaluateJavascript(CTRL_KEY_HANDLER, null);
- }
-
- public void enableCtrlKey() {
- this.evaluateJavascript(ENABLE_CTRL_KEY, null);
- }
-
- @Override
- public void onAccessibilityStateChanged(boolean enabled) {
- Log.d(TAG, "accessibility " + enabled);
- adjustToA11yStateChange();
- }
-
- @Override
- public void onTouchExplorationStateChanged(boolean enabled) {
- Log.d(TAG, "touch exploration " + enabled);
- adjustToA11yStateChange();
- }
-
- private void adjustToA11yStateChange() {
- if (!mA11yManager.isEnabled()) {
- setFocusable(true);
- return;
- }
-
- // When accessibility is on, the webview itself doesn't have to be focusable. The (virtual)
- // edittext will be focusable to accept inputs. However, the webview has to be focusable for
- // an accessibility purpose so that users can read the contents in it or scroll the view.
- setFocusable(false);
- setFocusableInTouchMode(true);
- }
-
- // AccessibilityEvents for WebView are sent directly from WebContentsAccessibilityImpl to the
- // parent of WebView, without going through WebView. So, there's no WebView methods we can
- // override to intercept the event handling process. To work around this, we attach an
- // AccessibilityDelegate to the parent view where the events are sent to. And to guarantee that
- // the parent view exists, wait until the WebView is attached to the window by when the parent
- // must exist.
- private final AccessibilityDelegate mA11yEventFilter =
- new AccessibilityDelegate() {
- @Override
- public boolean onRequestSendAccessibilityEvent(
- ViewGroup host, View child, AccessibilityEvent e) {
- // We filter only the a11y events from the WebView
- if (child != TerminalView.this) {
- return super.onRequestSendAccessibilityEvent(host, child, e);
- }
- final int eventType = e.getEventType();
- switch (e.getEventType()) {
- // Skip reading texts that are too long. Right now, ttyd emits entire
- // text on the terminal to the live region, which is very annoying to
- // screen reader users.
- case AccessibilityEvent.TYPE_ANNOUNCEMENT:
- CharSequence text = e.getText().get(0); // there always is a text
- if (text.length() >= TEXT_TOO_LONG_TO_ANNOUNCE) {
- Log.i(TAG, "Announcement skipped because it's too long: " + text);
- return false;
- }
- break;
- }
- return super.onRequestSendAccessibilityEvent(host, child, e);
- }
- };
-
- @Override
- protected void onAttachedToWindow() {
- super.onAttachedToWindow();
- if (mA11yManager.isEnabled()) {
- View parent = (View) getParent();
- parent.setAccessibilityDelegate(mA11yEventFilter);
- }
- }
-
- private final AccessibilityNodeProvider mA11yNodeProvider =
- new AccessibilityNodeProvider() {
-
- /** Returns the original NodeProvider that WebView implements. */
- private AccessibilityNodeProvider getParent() {
- return TerminalView.super.getAccessibilityNodeProvider();
- }
-
- /** Convenience method for reading a string resource. */
- private String getString(int resId) {
- return TerminalView.this.getContext().getResources().getString(resId);
- }
-
- /** Checks if NodeInfo renders an empty line in the terminal. */
- private boolean isEmptyLine(AccessibilityNodeInfo info) {
- final CharSequence text = info.getText();
- // Node with no text is not consiered a line. ttyd emits at least one character,
- // which usually is NBSP.
- if (text == null) {
- return false;
- }
- for (int i = 0; i < text.length(); i++) {
- char c = text.charAt(i);
- // Note: don't use Characters.isWhitespace as it doesn't recognize NBSP as a
- // whitespace.
- if (!TextUtils.isWhitespace(c)) {
- return false;
- }
- }
- return true;
- }
-
- @Override
- public AccessibilityNodeInfo createAccessibilityNodeInfo(int id) {
- AccessibilityNodeInfo info = getParent().createAccessibilityNodeInfo(id);
- if (info == null) {
- return null;
- }
-
- final String className = info.getClassName().toString();
-
- // By default all views except the cursor is not click-able. Other views are
- // read-only. This ensures that user is not navigated to non-clickable elements
- // when using switches.
- if (!"android.widget.EditText".equals(className)) {
- info.removeAction(AccessibilityAction.ACTION_CLICK);
- }
-
- switch (className) {
- case "android.webkit.WebView":
- // There are two NodeInfo objects of class name WebView. The one is the
- // real WebView whose ID is View.NO_ID as it's at the root of the
- // virtual view hierarchy. The second one is a virtual view for the
- // iframe. The latter one's text is set to the command that we give to
- // ttyd, which is "login -f droid ...". This is an impl detail which
- // doesn't have to be announced. Replace the text with "Terminal
- // display".
- if (id != View.NO_ID) {
- info.setText(null);
- info.setContentDescription(getString(R.string.terminal_display));
- // b/376827536
- info.setHintText(getString(R.string.double_tap_to_edit_text));
- }
-
- // These two lines below are to prevent this WebView element from being
- // fousable by the screen reader, while allowing any other element in
- // the WebView to be focusable by the reader. In our case, the EditText
- // is a117_focusable.
- info.setScreenReaderFocusable(false);
- info.addAction(AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS);
- break;
- case "android.view.View":
- // Empty line was announced as "space" (via the NBSP character).
- // Localize the spoken text.
- if (isEmptyLine(info)) {
- info.setContentDescription(getString(R.string.empty_line));
- // b/376827536
- info.setHintText(getString(R.string.double_tap_to_edit_text));
- }
- break;
- case "android.widget.TextView":
- // There are several TextViews in the terminal, and one of them is an
- // invisible TextView which seems to be from the <div
- // class="live-region"> tag. Interestingly, its text is often populated
- // with the entire text on the screen. Silence this by forcibly setting
- // the text to null. Note that this TextView is identified by having a
- // zero width. This certainly is not elegant, but I couldn't find other
- // options.
- Rect rect = new Rect();
- info.getBoundsInScreen(rect);
- if (rect.width() == 0) {
- info.setText(null);
- info.setContentDescription(getString(R.string.empty_line));
- }
- info.setScreenReaderFocusable(false);
- break;
- case "android.widget.EditText":
- // This EditText is for the <textarea> accepting user input; the cursor.
- // ttyd name it as "Terminal input" but it's not i18n'ed. Override it
- // here for better i18n.
- info.setText(null);
- info.setHintText(getString(R.string.double_tap_to_edit_text));
- info.setContentDescription(getString(R.string.terminal_input));
- info.setScreenReaderFocusable(true);
- info.addAction(AccessibilityAction.ACTION_FOCUS);
- break;
- }
- return info;
- }
-
- @Override
- public boolean performAction(int id, int action, Bundle arguments) {
- return getParent().performAction(id, action, arguments);
- }
-
- @Override
- public void addExtraDataToAccessibilityNodeInfo(
- int virtualViewId,
- AccessibilityNodeInfo info,
- String extraDataKey,
- Bundle arguments) {
- getParent()
- .addExtraDataToAccessibilityNodeInfo(
- virtualViewId, info, extraDataKey, arguments);
- }
-
- @Override
- public List<AccessibilityNodeInfo> findAccessibilityNodeInfosByText(
- String text, int virtualViewId) {
- return getParent().findAccessibilityNodeInfosByText(text, virtualViewId);
- }
-
- @Override
- public AccessibilityNodeInfo findFocus(int focus) {
- return getParent().findFocus(focus);
- }
- };
-
- @Override
- public AccessibilityNodeProvider getAccessibilityNodeProvider() {
- AccessibilityNodeProvider p = super.getAccessibilityNodeProvider();
- if (p != null && mA11yManager.isEnabled()) {
- return mA11yNodeProvider;
- }
- return p;
- }
-
- @Override
- public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
- InputConnection inputConnection = super.onCreateInputConnection(outAttrs);
- if (outAttrs != null) {
- outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
- }
- return inputConnection;
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
new file mode 100644
index 0000000..18a39fa
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
@@ -0,0 +1,283 @@
+/*
+ * Copyright (C) 2024 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.graphics.Rect
+import android.os.Bundle
+import android.text.InputType
+import android.text.TextUtils
+import android.util.AttributeSet
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityManager
+import android.view.accessibility.AccessibilityNodeInfo
+import android.view.accessibility.AccessibilityNodeProvider
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import android.webkit.WebView
+import com.android.virtualization.terminal.MainActivity.TAG
+import java.io.IOException
+
+class TerminalView(context: Context, attrs: AttributeSet?) :
+ WebView(context, attrs),
+ AccessibilityManager.AccessibilityStateChangeListener,
+ AccessibilityManager.TouchExplorationStateChangeListener {
+ private val ctrlKeyHandler: String = readAssetAsString(context, "js/ctrl_key_handler.js")
+ private val enableCtrlKey: String = readAssetAsString(context, "js/enable_ctrl_key.js")
+ private val touchToMouseHandler: String =
+ readAssetAsString(context, "js/touch_to_mouse_handler.js")
+ private val a11yManager =
+ context.getSystemService<AccessibilityManager>(AccessibilityManager::class.java).also {
+ it.addTouchExplorationStateChangeListener(this)
+ it.addAccessibilityStateChangeListener(this)
+ }
+
+ @Throws(IOException::class)
+ private fun readAssetAsString(context: Context, filePath: String): String {
+ return String(context.assets.open(filePath).readAllBytes())
+ }
+
+ fun mapTouchToMouseEvent() {
+ this.evaluateJavascript(touchToMouseHandler, null)
+ }
+
+ fun mapCtrlKey() {
+ this.evaluateJavascript(ctrlKeyHandler, null)
+ }
+
+ fun enableCtrlKey() {
+ this.evaluateJavascript(enableCtrlKey, null)
+ }
+
+ override fun onAccessibilityStateChanged(enabled: Boolean) {
+ Log.d(TAG, "accessibility $enabled")
+ adjustToA11yStateChange()
+ }
+
+ override fun onTouchExplorationStateChanged(enabled: Boolean) {
+ Log.d(TAG, "touch exploration $enabled")
+ adjustToA11yStateChange()
+ }
+
+ private fun adjustToA11yStateChange() {
+ if (!a11yManager.isEnabled) {
+ setFocusable(true)
+ return
+ }
+
+ // When accessibility is on, the webview itself doesn't have to be focusable. The (virtual)
+ // edittext will be focusable to accept inputs. However, the webview has to be focusable for
+ // an accessibility purpose so that users can read the contents in it or scroll the view.
+ setFocusable(false)
+ setFocusableInTouchMode(true)
+ }
+
+ // AccessibilityEvents for WebView are sent directly from WebContentsAccessibilityImpl to the
+ // parent of WebView, without going through WebView. So, there's no WebView methods we can
+ // override to intercept the event handling process. To work around this, we attach an
+ // AccessibilityDelegate to the parent view where the events are sent to. And to guarantee that
+ // the parent view exists, wait until the WebView is attached to the window by when the parent
+ // must exist.
+ private val mA11yEventFilter: AccessibilityDelegate =
+ object : AccessibilityDelegate() {
+ override fun onRequestSendAccessibilityEvent(
+ host: ViewGroup,
+ child: View,
+ e: AccessibilityEvent,
+ ): Boolean {
+ // We filter only the a11y events from the WebView
+ if (child !== this@TerminalView) {
+ return super.onRequestSendAccessibilityEvent(host, child, e)
+ }
+ when (e.eventType) {
+ AccessibilityEvent.TYPE_ANNOUNCEMENT -> {
+ val text = e.text[0] // there always is a text
+ if (text.length >= TEXT_TOO_LONG_TO_ANNOUNCE) {
+ Log.i(TAG, "Announcement skipped because it's too long: $text")
+ return false
+ }
+ }
+ }
+ return super.onRequestSendAccessibilityEvent(host, child, e)
+ }
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ if (a11yManager.isEnabled) {
+ val parent = getParent() as View
+ parent.setAccessibilityDelegate(mA11yEventFilter)
+ }
+ }
+
+ private val mA11yNodeProvider: AccessibilityNodeProvider =
+ object : AccessibilityNodeProvider() {
+ /** Returns the original NodeProvider that WebView implements. */
+ private fun getParent(): AccessibilityNodeProvider? {
+ return super@TerminalView.getAccessibilityNodeProvider()
+ }
+
+ /** Convenience method for reading a string resource. */
+ private fun getString(resId: Int): String {
+ return this@TerminalView.context.getResources().getString(resId)
+ }
+
+ /** Checks if NodeInfo renders an empty line in the terminal. */
+ private fun isEmptyLine(info: AccessibilityNodeInfo): Boolean {
+ // Node with no text is not considered a line. ttyd emits at least one character,
+ // which usually is NBSP.
+ // Note: don't use Characters.isWhitespace as it doesn't recognize NBSP as a
+ // whitespace.
+ return (info.getText()?.all { TextUtils.isWhitespace(it.code) }) == true
+ }
+
+ override fun createAccessibilityNodeInfo(id: Int): AccessibilityNodeInfo? {
+ val info: AccessibilityNodeInfo? = getParent()?.createAccessibilityNodeInfo(id)
+ if (info == null) {
+ return null
+ }
+
+ val className = info.className.toString()
+
+ // By default all views except the cursor is not click-able. Other views are
+ // read-only. This ensures that user is not navigated to non-clickable elements
+ // when using switches.
+ if ("android.widget.EditText" != className) {
+ info.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK)
+ }
+
+ when (className) {
+ "android.webkit.WebView" -> {
+ // There are two NodeInfo objects of class name WebView. The one is the
+ // real WebView whose ID is View.NO_ID as it's at the root of the
+ // virtual view hierarchy. The second one is a virtual view for the
+ // iframe. The latter one's text is set to the command that we give to
+ // ttyd, which is "login -f droid ...". This is an impl detail which
+ // doesn't have to be announced. Replace the text with "Terminal
+ // display".
+ if (id != NO_ID) {
+ info.setText(null)
+ info.setContentDescription(getString(R.string.terminal_display))
+ // b/376827536
+ info.setHintText(getString(R.string.double_tap_to_edit_text))
+ }
+
+ // These two lines below are to prevent this WebView element from being
+ // focusable by the screen reader, while allowing any other element in
+ // the WebView to be focusable by the reader. In our case, the EditText
+ // is a117_focusable.
+ info.isScreenReaderFocusable = false
+ info.addAction(
+ AccessibilityNodeInfo.AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS
+ )
+ }
+
+ "android.view.View" ->
+ // Empty line was announced as "space" (via the NBSP character).
+ // Localize the spoken text.
+ if (isEmptyLine(info)) {
+ info.setContentDescription(getString(R.string.empty_line))
+ // b/376827536
+ info.setHintText(getString(R.string.double_tap_to_edit_text))
+ }
+
+ "android.widget.TextView" -> {
+ // There are several TextViews in the terminal, and one of them is an
+ // invisible TextView which seems to be from the <div
+ // class="live-region"> tag. Interestingly, its text is often populated
+ // with the entire text on the screen. Silence this by forcibly setting
+ // the text to null. Note that this TextView is identified by having a
+ // zero width. This certainly is not elegant, but I couldn't find other
+ // options.
+ val rect = Rect()
+ info.getBoundsInScreen(rect)
+ if (rect.width() == 0) {
+ info.setText(null)
+ info.setContentDescription(getString(R.string.empty_line))
+ }
+ info.isScreenReaderFocusable = false
+ }
+
+ "android.widget.EditText" -> {
+ // This EditText is for the <textarea> accepting user input; the cursor.
+ // ttyd name it as "Terminal input" but it's not i18n'ed. Override it
+ // here for better i18n.
+ info.setText(null)
+ info.setHintText(getString(R.string.double_tap_to_edit_text))
+ info.setContentDescription(getString(R.string.terminal_input))
+ info.isScreenReaderFocusable = true
+ info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_FOCUS)
+ }
+ }
+ return info
+ }
+
+ override fun performAction(id: Int, action: Int, arguments: Bundle?): Boolean {
+ return getParent()?.performAction(id, action, arguments) == true
+ }
+
+ override fun addExtraDataToAccessibilityNodeInfo(
+ virtualViewId: Int,
+ info: AccessibilityNodeInfo?,
+ extraDataKey: String?,
+ arguments: Bundle?,
+ ) {
+ getParent()
+ ?.addExtraDataToAccessibilityNodeInfo(
+ virtualViewId,
+ info,
+ extraDataKey,
+ arguments,
+ )
+ }
+
+ override fun findAccessibilityNodeInfosByText(
+ text: String?,
+ virtualViewId: Int,
+ ): MutableList<AccessibilityNodeInfo?>? {
+ return getParent()?.findAccessibilityNodeInfosByText(text, virtualViewId)
+ }
+
+ override fun findFocus(focus: Int): AccessibilityNodeInfo? {
+ return getParent()?.findFocus(focus)
+ }
+ }
+
+ override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider? {
+ val p = super.getAccessibilityNodeProvider()
+ if (p != null && a11yManager.isEnabled) {
+ return mA11yNodeProvider
+ }
+ return p
+ }
+
+ override fun onCreateInputConnection(outAttrs: EditorInfo?): InputConnection? {
+ val inputConnection = super.onCreateInputConnection(outAttrs)
+ if (outAttrs != null) {
+ outAttrs.inputType = outAttrs.inputType or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
+ }
+ return inputConnection
+ }
+
+ companion object {
+ // Maximum length of texts the talk back announcements can be. This value is somewhat
+ // arbitrarily set. We may want to adjust this in the future.
+ private const val TEXT_TOO_LONG_TO_ANNOUNCE = 200
+ }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java
deleted file mode 100644
index 09b58d3..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java
+++ /dev/null
@@ -1,375 +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 com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.app.Notification;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.app.Service;
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.drawable.Icon;
-import android.net.nsd.NsdManager;
-import android.net.nsd.NsdServiceInfo;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.IBinder;
-import android.os.Looper;
-import android.os.Parcel;
-import android.os.ResultReceiver;
-import android.system.virtualmachine.VirtualMachine;
-import android.system.virtualmachine.VirtualMachineConfig;
-import android.system.virtualmachine.VirtualMachineCustomImageConfig;
-import android.system.virtualmachine.VirtualMachineCustomImageConfig.Disk;
-import android.system.virtualmachine.VirtualMachineException;
-import android.util.Log;
-import android.widget.Toast;
-
-import io.grpc.Grpc;
-import io.grpc.InsecureServerCredentials;
-import io.grpc.Metadata;
-import io.grpc.Server;
-import io.grpc.ServerCall;
-import io.grpc.ServerCallHandler;
-import io.grpc.ServerInterceptor;
-import io.grpc.Status;
-import io.grpc.okhttp.OkHttpServerBuilder;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Objects;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
-public class VmLauncherService extends Service {
- private static final String EXTRA_NOTIFICATION = "EXTRA_NOTIFICATION";
- private static final String ACTION_START_VM_LAUNCHER_SERVICE =
- "android.virtualization.START_VM_LAUNCHER_SERVICE";
-
- public static final String ACTION_STOP_VM_LAUNCHER_SERVICE =
- "android.virtualization.STOP_VM_LAUNCHER_SERVICE";
-
- private static final int RESULT_START = 0;
- private static final int RESULT_STOP = 1;
- private static final int RESULT_ERROR = 2;
-
- private ExecutorService mExecutorService;
- private VirtualMachine mVirtualMachine;
- private ResultReceiver mResultReceiver;
- private Server mServer;
- private DebianServiceImpl mDebianService;
- private PortNotifier mPortNotifier;
-
- private static Intent getMyIntent(Context context) {
- return new Intent(context.getApplicationContext(), VmLauncherService.class);
- }
-
- public interface VmLauncherServiceCallback {
- void onVmStart();
-
- void onVmStop();
-
- void onVmError();
- }
-
- public static void run(
- Context context, VmLauncherServiceCallback callback, Notification notification) {
- Intent i = getMyIntent(context);
- if (i == null) {
- return;
- }
- ResultReceiver resultReceiver =
- new ResultReceiver(new Handler(Looper.myLooper())) {
- @Override
- protected void onReceiveResult(int resultCode, Bundle resultData) {
- if (callback == null) {
- return;
- }
- switch (resultCode) {
- case RESULT_START:
- callback.onVmStart();
- return;
- case RESULT_STOP:
- callback.onVmStop();
- return;
- case RESULT_ERROR:
- callback.onVmError();
- return;
- }
- }
- };
- i.putExtra(Intent.EXTRA_RESULT_RECEIVER, getResultReceiverForIntent(resultReceiver));
- i.putExtra(VmLauncherService.EXTRA_NOTIFICATION, notification);
- context.startForegroundService(i);
- }
-
- private static ResultReceiver getResultReceiverForIntent(ResultReceiver r) {
- Parcel parcel = Parcel.obtain();
- r.writeToParcel(parcel, 0);
- parcel.setDataPosition(0);
- r = ResultReceiver.CREATOR.createFromParcel(parcel);
- parcel.recycle();
- return r;
- }
-
- @Override
- public IBinder onBind(Intent intent) {
- return null;
- }
-
- @Override
- public int onStartCommand(Intent intent, int flags, int startId) {
- if (Objects.equals(intent.getAction(), ACTION_STOP_VM_LAUNCHER_SERVICE)) {
-
- if (mDebianService != null && mDebianService.shutdownDebian()) {
- // During shutdown, change the notification content to indicate that it's closing
- Notification notification = createNotificationForTerminalClose();
- getSystemService(NotificationManager.class).notify(this.hashCode(), notification);
- } else {
- // If there is no Debian service or it fails to shutdown, just stop the service.
- stopSelf();
- }
- return START_NOT_STICKY;
- }
- if (mVirtualMachine != null) {
- Log.d(TAG, "VM instance is already started");
- return START_NOT_STICKY;
- }
- mExecutorService =
- Executors.newCachedThreadPool(new TerminalThreadFactory(getApplicationContext()));
-
- InstalledImage image = InstalledImage.getDefault(this);
- ConfigJson json = ConfigJson.from(this, image.getConfigPath());
- VirtualMachineConfig.Builder configBuilder = json.toConfigBuilder(this);
- VirtualMachineCustomImageConfig.Builder customImageConfigBuilder =
- json.toCustomImageConfigBuilder(this);
- if (overrideConfigIfNecessary(customImageConfigBuilder)) {
- configBuilder.setCustomImageConfig(customImageConfigBuilder.build());
- }
- VirtualMachineConfig config = configBuilder.build();
-
- Runner runner;
- try {
- android.os.Trace.beginSection("vmCreate");
- runner = Runner.create(this, config);
- android.os.Trace.endSection();
- android.os.Trace.beginAsyncSection("debianBoot", 0);
- } catch (VirtualMachineException e) {
- throw new RuntimeException("cannot create runner", e);
- }
- mVirtualMachine = runner.getVm();
- mResultReceiver =
- intent.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver.class);
-
- runner.getExitStatus()
- .thenAcceptAsync(
- success -> {
- if (mResultReceiver != null) {
- mResultReceiver.send(success ? RESULT_STOP : RESULT_ERROR, null);
- }
- stopSelf();
- });
- Path logPath = getFileStreamPath(mVirtualMachine.getName() + ".log").toPath();
- Logger.setup(mVirtualMachine, logPath, mExecutorService);
-
- Notification notification =
- intent.getParcelableExtra(EXTRA_NOTIFICATION, Notification.class);
-
- startForeground(this.hashCode(), notification);
-
- mResultReceiver.send(RESULT_START, null);
-
- mPortNotifier = new PortNotifier(this);
-
- // TODO: dedup this part
- 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();
- startDebianServer(ipAddress);
- }
- });
-
- return START_NOT_STICKY;
- }
-
- private Notification createNotificationForTerminalClose() {
- 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);
- String stopActionText =
- getResources().getString(R.string.service_notification_force_quit_action);
- String stopNotificationTitle =
- getResources().getString(R.string.service_notification_close_title);
- return new Notification.Builder(this, this.getPackageName())
- .setSmallIcon(R.drawable.ic_launcher_foreground)
- .setContentTitle(stopNotificationTitle)
- .setOngoing(true)
- .setSilent(true)
- .addAction(
- new Notification.Action.Builder(icon, stopActionText, stopPendingIntent)
- .build())
- .build();
- }
-
- private boolean overrideConfigIfNecessary(VirtualMachineCustomImageConfig.Builder builder) {
- boolean changed = false;
- // TODO: check if ANGLE is enabled for the app.
- if (Files.exists(ImageArchive.getSdcardPathForTesting().resolve("virglrenderer"))) {
- builder.setGpuConfig(
- new VirtualMachineCustomImageConfig.GpuConfig.Builder()
- .setBackend("virglrenderer")
- .setRendererUseEgl(true)
- .setRendererUseGles(true)
- .setRendererUseGlx(false)
- .setRendererUseSurfaceless(true)
- .setRendererUseVulkan(false)
- .setContextTypes(new String[] {"virgl2"})
- .build());
- Toast.makeText(this, R.string.virgl_enabled, Toast.LENGTH_SHORT).show();
- changed = true;
- }
-
- InstalledImage image = InstalledImage.getDefault(this);
- if (image.hasBackup()) {
- Path backup = image.getBackupFile();
- builder.addDisk(Disk.RWDisk(backup.toString()));
- changed = true;
- }
- return changed;
- }
-
- private void startDebianServer(String ipAddress) {
- ServerInterceptor interceptor =
- new ServerInterceptor() {
- @Override
- public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
- ServerCall<ReqT, RespT> call,
- Metadata headers,
- ServerCallHandler<ReqT, RespT> next) {
- InetSocketAddress remoteAddr =
- (InetSocketAddress)
- call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR);
-
- if (remoteAddr != null
- && Objects.equals(
- remoteAddr.getAddress().getHostAddress(), ipAddress)) {
- // Allow the request only if it is from VM
- return next.startCall(call, headers);
- }
- Log.d(TAG, "blocked grpc request from " + remoteAddr);
- call.close(Status.Code.PERMISSION_DENIED.toStatus(), new Metadata());
- return new ServerCall.Listener<ReqT>() {};
- }
- };
- try {
- // TODO(b/372666638): gRPC for java doesn't support vsock for now.
- int port = 0;
- mDebianService = new DebianServiceImpl(this);
- mServer =
- OkHttpServerBuilder.forPort(port, InsecureServerCredentials.create())
- .intercept(interceptor)
- .addService(mDebianService)
- .build()
- .start();
- } catch (IOException e) {
- Log.d(TAG, "grpc server error", e);
- return;
- }
-
- mExecutorService.execute(
- () -> {
- // TODO(b/373533555): we can use mDNS for that.
- String debianServicePortFileName = "debian_service_port";
- File debianServicePortFile = new File(getFilesDir(), debianServicePortFileName);
- try (FileOutputStream writer = new FileOutputStream(debianServicePortFile)) {
- writer.write(String.valueOf(mServer.getPort()).getBytes());
- } catch (IOException e) {
- Log.d(TAG, "cannot write grpc port number", e);
- }
- });
- }
-
- public static void stop(Context context) {
- Intent i = getMyIntent(context);
- i.setAction(VmLauncherService.ACTION_STOP_VM_LAUNCHER_SERVICE);
- context.startService(i);
- }
-
- @Override
- public void onDestroy() {
- if (mPortNotifier != null) {
- mPortNotifier.stop();
- }
- getSystemService(NotificationManager.class).cancelAll();
- stopDebianServer();
- if (mVirtualMachine != null) {
- if (mVirtualMachine.getStatus() == VirtualMachine.STATUS_RUNNING) {
- try {
- mVirtualMachine.stop();
- stopForeground(STOP_FOREGROUND_REMOVE);
- } catch (VirtualMachineException e) {
- Log.e(TAG, "failed to stop a VM instance", e);
- }
- }
- mExecutorService.shutdownNow();
- mExecutorService = null;
- mVirtualMachine = null;
- }
- super.onDestroy();
- }
-
- private void stopDebianServer() {
- if (mDebianService != null) {
- mDebianService.killForwarderHost();
- }
- if (mServer != null) {
- mServer.shutdown();
- }
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
new file mode 100644
index 0000000..2796b86
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
@@ -0,0 +1,355 @@
+/*
+ * 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.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.graphics.drawable.Icon
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.Parcel
+import android.os.ResultReceiver
+import android.os.Trace
+import android.system.virtualmachine.VirtualMachine
+import android.system.virtualmachine.VirtualMachineCustomImageConfig
+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.Runner.Companion.create
+import com.android.virtualization.terminal.VmLauncherService.VmLauncherServiceCallback
+import io.grpc.Grpc
+import io.grpc.InsecureServerCredentials
+import io.grpc.Metadata
+import io.grpc.Server
+import io.grpc.ServerCall
+import io.grpc.ServerCallHandler
+import io.grpc.ServerInterceptor
+import io.grpc.Status
+import io.grpc.okhttp.OkHttpServerBuilder
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.lang.RuntimeException
+import java.net.InetSocketAddress
+import java.net.SocketAddress
+import java.nio.file.Files
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+class VmLauncherService : Service() {
+ // TODO: using lateinit for some fields to avoid null
+ private var mExecutorService: ExecutorService? = null
+ private var mVirtualMachine: VirtualMachine? = null
+ private var mResultReceiver: ResultReceiver? = null
+ private var mServer: Server? = null
+ private var mDebianService: DebianServiceImpl? = null
+ private var mPortNotifier: PortNotifier? = null
+
+ interface VmLauncherServiceCallback {
+ fun onVmStart()
+
+ fun onVmStop()
+
+ fun onVmError()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? {
+ return null
+ }
+
+ override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+ if (intent.action == ACTION_STOP_VM_LAUNCHER_SERVICE) {
+ if (mDebianService != null && mDebianService!!.shutdownDebian()) {
+ // During shutdown, change the notification content to indicate that it's closing
+ val notification = createNotificationForTerminalClose()
+ getSystemService<NotificationManager?>(NotificationManager::class.java)
+ .notify(this.hashCode(), notification)
+ } else {
+ // If there is no Debian service or it fails to shutdown, just stop the service.
+ stopSelf()
+ }
+ return START_NOT_STICKY
+ }
+ if (mVirtualMachine != null) {
+ Log.d(TAG, "VM instance is already started")
+ return START_NOT_STICKY
+ }
+ mExecutorService = Executors.newCachedThreadPool(TerminalThreadFactory(applicationContext))
+
+ val image = InstalledImage.getDefault(this)
+ val json = ConfigJson.from(this, image.configPath)
+ val configBuilder = json.toConfigBuilder(this)
+ val customImageConfigBuilder = json.toCustomImageConfigBuilder(this)
+ if (overrideConfigIfNecessary(customImageConfigBuilder)) {
+ configBuilder.setCustomImageConfig(customImageConfigBuilder.build())
+ }
+ val config = configBuilder.build()
+
+ Trace.beginSection("vmCreate")
+ val runner: Runner =
+ try {
+ create(this, config)
+ } catch (e: VirtualMachineException) {
+ throw RuntimeException("cannot create runner", e)
+ }
+ Trace.endSection()
+ Trace.beginAsyncSection("debianBoot", 0)
+
+ mVirtualMachine = runner.vm
+ mResultReceiver =
+ intent.getParcelableExtra<ResultReceiver?>(
+ Intent.EXTRA_RESULT_RECEIVER,
+ ResultReceiver::class.java,
+ )
+
+ runner.exitStatus.thenAcceptAsync { success: Boolean ->
+ mResultReceiver?.send(if (success) RESULT_STOP else RESULT_ERROR, null)
+ stopSelf()
+ }
+ val logPath = getFileStreamPath(mVirtualMachine!!.name + ".log").toPath()
+ Logger.setup(mVirtualMachine!!, logPath, mExecutorService!!)
+
+ val notification =
+ intent.getParcelableExtra<Notification?>(EXTRA_NOTIFICATION, Notification::class.java)
+
+ startForeground(this.hashCode(), notification)
+
+ mResultReceiver!!.send(RESULT_START, null)
+
+ mPortNotifier = PortNotifier(this)
+
+ // TODO: dedup this part
+ val nsdManager = getSystemService<NsdManager?>(NsdManager::class.java)
+ val info = NsdServiceInfo()
+ info.serviceType = "_http._tcp"
+ info.serviceName = "ttyd"
+ nsdManager.registerServiceInfoCallback(
+ info,
+ mExecutorService!!,
+ 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")
+ startDebianServer(info.hostAddresses[0].hostAddress)
+ }
+ },
+ )
+
+ return START_NOT_STICKY
+ }
+
+ private fun createNotificationForTerminalClose(): Notification {
+ val stopIntent = Intent()
+ stopIntent.setClass(this, VmLauncherService::class.java)
+ stopIntent.setAction(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 stopActionText: String? =
+ resources.getString(R.string.service_notification_force_quit_action)
+ val stopNotificationTitle: String? =
+ resources.getString(R.string.service_notification_close_title)
+ return Notification.Builder(this, this.packageName)
+ .setSmallIcon(R.drawable.ic_launcher_foreground)
+ .setContentTitle(stopNotificationTitle)
+ .setOngoing(true)
+ .setSilent(true)
+ .addAction(Notification.Action.Builder(icon, stopActionText, stopPendingIntent).build())
+ .build()
+ }
+
+ private fun overrideConfigIfNecessary(
+ builder: VirtualMachineCustomImageConfig.Builder
+ ): Boolean {
+ var changed = false
+ // TODO: check if ANGLE is enabled for the app.
+ if (Files.exists(ImageArchive.getSdcardPathForTesting().resolve("virglrenderer"))) {
+ builder.setGpuConfig(
+ VirtualMachineCustomImageConfig.GpuConfig.Builder()
+ .setBackend("virglrenderer")
+ .setRendererUseEgl(true)
+ .setRendererUseGles(true)
+ .setRendererUseGlx(false)
+ .setRendererUseSurfaceless(true)
+ .setRendererUseVulkan(false)
+ .setContextTypes(arrayOf<String>("virgl2"))
+ .build()
+ )
+ Toast.makeText(this, R.string.virgl_enabled, Toast.LENGTH_SHORT).show()
+ changed = true
+ }
+
+ val image = InstalledImage.getDefault(this)
+ if (image.hasBackup()) {
+ val backup = image.backupFile
+ builder.addDisk(VirtualMachineCustomImageConfig.Disk.RWDisk(backup.toString()))
+ changed = true
+ }
+ return changed
+ }
+
+ private fun startDebianServer(ipAddress: String?) {
+ val interceptor: ServerInterceptor =
+ object : ServerInterceptor {
+ override fun <ReqT, RespT> interceptCall(
+ call: ServerCall<ReqT?, RespT?>,
+ headers: Metadata?,
+ next: ServerCallHandler<ReqT?, RespT?>,
+ ): ServerCall.Listener<ReqT?>? {
+ val remoteAddr =
+ call.attributes.get<SocketAddress?>(Grpc.TRANSPORT_ATTR_REMOTE_ADDR)
+ as InetSocketAddress?
+
+ if (remoteAddr?.address?.hostAddress == ipAddress) {
+ // Allow the request only if it is from VM
+ return next.startCall(call, headers)
+ }
+ Log.d(TAG, "blocked grpc request from $remoteAddr")
+ call.close(Status.Code.PERMISSION_DENIED.toStatus(), Metadata())
+ return object : ServerCall.Listener<ReqT?>() {}
+ }
+ }
+ try {
+ // TODO(b/372666638): gRPC for java doesn't support vsock for now.
+ val port = 0
+ mDebianService = DebianServiceImpl(this)
+ mServer =
+ OkHttpServerBuilder.forPort(port, InsecureServerCredentials.create())
+ .intercept(interceptor)
+ .addService(mDebianService)
+ .build()
+ .start()
+ } catch (e: IOException) {
+ Log.d(TAG, "grpc server error", e)
+ return
+ }
+
+ mExecutorService!!.execute(
+ Runnable {
+ // TODO(b/373533555): we can use mDNS for that.
+ val debianServicePortFile = File(filesDir, "debian_service_port")
+ try {
+ FileOutputStream(debianServicePortFile).use { writer ->
+ writer.write(mServer!!.port.toString().toByteArray())
+ }
+ } catch (e: IOException) {
+ Log.d(TAG, "cannot write grpc port number", e)
+ }
+ }
+ )
+ }
+
+ override fun onDestroy() {
+ mPortNotifier?.stop()
+ getSystemService<NotificationManager?>(NotificationManager::class.java).cancelAll()
+ stopDebianServer()
+ if (mVirtualMachine != null) {
+ if (mVirtualMachine!!.getStatus() == VirtualMachine.STATUS_RUNNING) {
+ try {
+ mVirtualMachine!!.stop()
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ } catch (e: VirtualMachineException) {
+ Log.e(TAG, "failed to stop a VM instance", e)
+ }
+ }
+ mExecutorService?.shutdownNow()
+ mExecutorService = null
+ mVirtualMachine = null
+ }
+ super.onDestroy()
+ }
+
+ private fun stopDebianServer() {
+ mDebianService?.killForwarderHost()
+ mServer?.shutdown()
+ }
+
+ companion object {
+ private const val EXTRA_NOTIFICATION = "EXTRA_NOTIFICATION"
+ private const val ACTION_START_VM_LAUNCHER_SERVICE =
+ "android.virtualization.START_VM_LAUNCHER_SERVICE"
+
+ const val ACTION_STOP_VM_LAUNCHER_SERVICE: String =
+ "android.virtualization.STOP_VM_LAUNCHER_SERVICE"
+
+ private const val RESULT_START = 0
+ private const val RESULT_STOP = 1
+ private const val RESULT_ERROR = 2
+
+ private fun getMyIntent(context: Context): Intent {
+ return Intent(context.getApplicationContext(), VmLauncherService::class.java)
+ }
+
+ @JvmStatic
+ fun run(
+ context: Context,
+ callback: VmLauncherServiceCallback?,
+ notification: Notification?,
+ ) {
+ val i = getMyIntent(context)
+ val resultReceiver: ResultReceiver =
+ object : ResultReceiver(Handler(Looper.myLooper()!!)) {
+ override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
+ if (callback == null) {
+ return
+ }
+ when (resultCode) {
+ RESULT_START -> callback.onVmStart()
+ RESULT_STOP -> callback.onVmStop()
+ RESULT_ERROR -> callback.onVmError()
+ }
+ }
+ }
+ i.putExtra(Intent.EXTRA_RESULT_RECEIVER, getResultReceiverForIntent(resultReceiver))
+ i.putExtra(EXTRA_NOTIFICATION, notification)
+ context.startForegroundService(i)
+ }
+
+ private fun getResultReceiverForIntent(r: ResultReceiver): ResultReceiver {
+ val parcel = Parcel.obtain()
+ r.writeToParcel(parcel, 0)
+ parcel.setDataPosition(0)
+ return ResultReceiver.CREATOR.createFromParcel(parcel).also { parcel.recycle() }
+ }
+
+ @JvmStatic
+ fun stop(context: Context) {
+ val i = getMyIntent(context)
+ i.setAction(ACTION_STOP_VM_LAUNCHER_SERVICE)
+ context.startService(i)
+ }
+ }
+}
diff --git a/android/virtmgr/src/crosvm.rs b/android/virtmgr/src/crosvm.rs
index a90c1ff..096d3b5 100644
--- a/android/virtmgr/src/crosvm.rs
+++ b/android/virtmgr/src/crosvm.rs
@@ -324,7 +324,7 @@
let tap =
if let Some(tap_file) = &config.tap { Some(tap_file.try_clone()?) } else { None };
- run_virtiofs(&config)?;
+ let vhost_fs_devices = run_virtiofs(&config)?;
// If this fails and returns an error, `self` will be left in the `Failed` state.
let child =
@@ -339,7 +339,13 @@
let child_clone = child.clone();
let instance_clone = instance.clone();
let monitor_vm_exit_thread = Some(thread::spawn(move || {
- instance_clone.monitor_vm_exit(child_clone, failure_pipe_read, vfio_devices, tap);
+ instance_clone.monitor_vm_exit(
+ child_clone,
+ failure_pipe_read,
+ vfio_devices,
+ tap,
+ vhost_fs_devices,
+ );
}));
if detect_hangup {
@@ -486,6 +492,7 @@
failure_pipe_read: File,
vfio_devices: Vec<VfioDevice>,
tap: Option<File>,
+ vhost_user_devices: Vec<SharedChild>,
) {
let failure_reason_thread = std::thread::spawn(move || {
// Read the pipe to see if any failure reason is written
@@ -513,6 +520,34 @@
}
}
+ // In crosvm, when vhost_user frontend is dead, vhost_user backend device will detect and
+ // exit. We can safely wait() for vhost user device after waiting crosvm main
+ // process.
+ for device in vhost_user_devices {
+ match device.wait() {
+ Ok(status) => {
+ info!("Vhost user device({}) exited with status {}", device.id(), status);
+ if !status.success() {
+ if let Some(code) = status.code() {
+ // vhost_user backend device exit with error code
+ error!(
+ "vhost user device({}) exited with error code: {}",
+ device.id(),
+ code
+ );
+ } else {
+ // The spawned child process of vhost_user backend device is
+ // killed by signal
+ error!("vhost user device({}) killed by signal", device.id());
+ }
+ }
+ }
+ Err(e) => {
+ error!("Error waiting for vhost user device({}) to die: {}", device.id(), e);
+ }
+ }
+ }
+
let failure_reason = failure_reason_thread.join().expect("failure_reason_thread panic'd");
let mut vm_state = self.vm_state.lock().unwrap();
@@ -915,7 +950,8 @@
}
}
-fn run_virtiofs(config: &CrosvmConfig) -> io::Result<()> {
+fn run_virtiofs(config: &CrosvmConfig) -> io::Result<Vec<SharedChild>> {
+ let mut devices: Vec<SharedChild> = Vec::new();
for shared_path in &config.shared_paths {
if shared_path.app_domain {
continue;
@@ -947,9 +983,10 @@
let result = SharedChild::spawn(&mut command)?;
info!("Spawned virtiofs crosvm({})", result.id());
+ devices.push(result);
}
- Ok(())
+ Ok(devices)
}
/// Starts an instance of `crosvm` to manage a new VM.
diff --git a/build/debian/build.sh b/build/debian/build.sh
index 3db6a40..3f33ec8 100755
--- a/build/debian/build.sh
+++ b/build/debian/build.sh
@@ -57,18 +57,16 @@
;;
esac
if [[ "${*:$OPTIND:1}" ]]; then
- built_image="${*:$OPTIND:1}"
+ output="${*:$OPTIND:1}"
fi
}
prepare_build_id() {
- local filename=build_id
if [ -z "${KOKORO_BUILD_NUMBER}" ]; then
- echo eng-$(hostname)-$(date --utc) > ${filename}
+ echo eng-$(hostname)-$(date --utc)
else
- echo ${KOKORO_BUILD_NUMBER} > ${filename}
+ echo ${KOKORO_BUILD_NUMBER}
fi
- echo ${filename}
}
install_prerequisites() {
@@ -302,17 +300,23 @@
}
run_fai() {
- local out="${built_image}"
+ local out="${raw_disk_image}"
make -C "${debian_cloud_image}" "image_bookworm_nocloud_${debian_arch}"
mv "${debian_cloud_image}/image_bookworm_nocloud_${debian_arch}.raw" "${out}"
}
-extract_partitions() {
- root_partition_num=1
- bios_partition_num=14
- efi_partition_num=15
+generate_output_package() {
+ fdisk -l "${raw_disk_image}"
+ local vm_config="$(realpath $(dirname "$0"))/vm_config.json.${arch}"
+ local root_partition_num=1
+ local bios_partition_num=14
+ local efi_partition_num=15
- loop=$(losetup -f --show --partscan $built_image)
+ pushd ${workdir} > /dev/null
+
+ echo ${build_id} > build_id
+
+ loop=$(losetup -f --show --partscan $raw_disk_image)
dd if="${loop}p$root_partition_num" of=root_part
if [[ "$arch" == "x86_64" ]]; then
dd if="${loop}p$bios_partition_num" of=bios_part
@@ -320,11 +324,38 @@
dd if="${loop}p$efi_partition_num" of=efi_part
losetup -d "${loop}"
- sed -i "s/{root_part_guid}/$(sfdisk --part-uuid $built_image $root_partition_num)/g" vm_config.json
+ cp ${vm_config} vm_config.json
+ sed -i "s/{root_part_guid}/$(sfdisk --part-uuid $raw_disk_image $root_partition_num)/g" vm_config.json
if [[ "$arch" == "x86_64" ]]; then
- sed -i "s/{bios_part_guid}/$(sfdisk --part-uuid $built_image $bios_partition_num)/g" vm_config.json
+ sed -i "s/{bios_part_guid}/$(sfdisk --part-uuid $raw_disk_image $bios_partition_num)/g" vm_config.json
fi
- sed -i "s/{efi_part_guid}/$(sfdisk --part-uuid $built_image $efi_partition_num)/g" vm_config.json
+ sed -i "s/{efi_part_guid}/$(sfdisk --part-uuid $raw_disk_image $efi_partition_num)/g" vm_config.json
+
+ images=()
+ if [[ "$arch" == "aarch64" ]]; then
+ images+=(
+ root_part
+ efi_part
+ )
+ # TODO(b/365955006): remove these lines when uboot supports x86_64 EFI application
+ elif [[ "$arch" == "x86_64" ]]; then
+ rm -f vmlinuz initrd.img
+ virt-get-kernel -a "${raw_disk_image}"
+ mv vmlinuz* vmlinuz
+ mv initrd.img* initrd.img
+ images+=(
+ bios_part
+ root_part
+ efi_part
+ vmlinuz
+ initrd.img
+ )
+ fi
+
+ popd > /dev/null
+
+ # --sparse option isn't supported in apache-commons-compress
+ tar czv -f ${output} -C ${workdir} build_id "${images[@]}" vm_config.json
}
clean_up() {
@@ -334,8 +365,9 @@
set -e
trap clean_up EXIT
-built_image=image.raw
+output=images.tar.gz
workdir=$(mktemp -d)
+raw_disk_image=${workdir}/image.raw
build_id=$(prepare_build_id)
debian_cloud_image=${workdir}/debian_cloud_image
debian_version=bookworm
@@ -353,32 +385,4 @@
copy_android_config
package_custom_kernel
run_fai
-fdisk -l "${built_image}"
-images=()
-
-cp "$(dirname "$0")/vm_config.json.${arch}" vm_config.json
-
-extract_partitions
-
-if [[ "$arch" == "aarch64" ]]; then
- images+=(
- root_part
- efi_part
- )
-# TODO(b/365955006): remove these lines when uboot supports x86_64 EFI application
-elif [[ "$arch" == "x86_64" ]]; then
- rm -f vmlinuz initrd.img
- virt-get-kernel -a "${built_image}"
- mv vmlinuz* vmlinuz
- mv initrd.img* initrd.img
- images+=(
- bios_part
- root_part
- efi_part
- vmlinuz
- initrd.img
- )
-fi
-
-# --sparse option isn't supported in apache-commons-compress
-tar czv -f images.tar.gz ${build_id} "${images[@]}" vm_config.json
+generate_output_package
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 0a7cca6..03657ed 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -9,7 +9,7 @@
* aosp\_oriole (Pixel 6)
* aosp\_raven (Pixel 6 Pro)
* aosp\_felix (Pixel Fold)
-* aosp\_tangopro (Pixel Tablet)
+* aosp\_tangorpro (Pixel Tablet)
* aosp\_cf\_x86\_64\_phone (Cuttlefish a.k.a. Cloud Android). Follow [this
instruction](https://source.android.com/docs/setup/create/cuttlefish-use) to
use.
diff --git a/libs/cstr/rules.mk b/libs/cstr/rules.mk
new file mode 100644
index 0000000..2309c30
--- /dev/null
+++ b/libs/cstr/rules.mk
@@ -0,0 +1,28 @@
+# 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.
+#
+
+LOCAL_DIR := $(GET_LOCAL_DIR)
+
+MODULE := $(LOCAL_DIR)
+
+SRC_DIR := packages/modules/Virtualization/libs/cstr
+
+MODULE_SRCS := $(SRC_DIR)/src/lib.rs
+
+MODULE_CRATE_NAME := cstr
+
+MODULE_RUST_EDITION := 2021
+
+include make/library.mk
diff --git a/libs/libavf/Android.bp b/libs/libavf/Android.bp
index 079f4ae..b583e21 100644
--- a/libs/libavf/Android.bp
+++ b/libs/libavf/Android.bp
@@ -10,6 +10,7 @@
source_stem: "bindings",
bindgen_flags: ["--default-enum-style rust"],
apex_available: ["com.android.virt"],
+ visibility: ["//packages/modules/Virtualization/tests/vts"],
}
rust_defaults {
diff --git a/libs/libavf/include/android/virtualization.h b/libs/libavf/include/android/virtualization.h
index 6b54bf7..ef57325 100644
--- a/libs/libavf/include/android/virtualization.h
+++ b/libs/libavf/include/android/virtualization.h
@@ -70,7 +70,13 @@
const char* _Nonnull name) __INTRODUCED_IN(36);
/**
- * Set an instance ID of a virtual machine.
+ * Set an instance ID of a virtual machine. Every virtual machine is identified by a unique
+ * `instanceId` which the virtual machine uses as its persistent identity while performing stateful
+ * operations that are expected to outlast single boot of the VM. For example, some virtual machines
+ * use it as a `Id` for storing secrets in Secretkeeper, which are retrieved on next boot of th VM.
+ *
+ * The `instanceId` is expected to be re-used for the VM instance with an associated state (secret,
+ * encrypted storage) - i.e., rebooting the VM must not change the instanceId.
*
* \param config a virtual machine config object.
* \param instanceId a pointer to a 64-byte buffer for the instance ID.
diff --git a/libs/libfdt/bindgen/rules.mk b/libs/libfdt/bindgen/rules.mk
new file mode 100644
index 0000000..130a317
--- /dev/null
+++ b/libs/libfdt/bindgen/rules.mk
@@ -0,0 +1,38 @@
+# 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.
+#
+
+LOCAL_DIR := $(GET_LOCAL_DIR)
+
+MODULE := $(LOCAL_DIR)
+
+MODULE_SRCS := $(LOCAL_DIR)/src/lib.rs
+
+MODULE_CRATE_NAME := libfdt_bindgen
+
+MODULE_DEPS += \
+ external/dtc/libfdt \
+
+MODULE_BINDGEN_ALLOW_FUNCTIONS := \
+ fdt_.* \
+
+MODULE_BINDGEN_ALLOW_VARS := \
+ FDT_.* \
+
+MODULE_BINDGEN_ALLOW_TYPES := \
+ fdt_.* \
+
+MODULE_BINDGEN_SRC_HEADER := $(LOCAL_DIR)/fdt.h
+
+include make/library.mk
diff --git a/libs/libfdt/bindgen/src/lib.rs b/libs/libfdt/bindgen/src/lib.rs
new file mode 100644
index 0000000..015132b
--- /dev/null
+++ b/libs/libfdt/bindgen/src/lib.rs
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+//! # Interface library for libfdt.
+
+#![no_std]
+#![allow(non_upper_case_globals)]
+#![allow(non_camel_case_types)]
+#![allow(non_snake_case)]
+
+include!(env!("BINDGEN_INC_FILE"));
diff --git a/libs/libfdt/rules.mk b/libs/libfdt/rules.mk
new file mode 100644
index 0000000..2b4e470
--- /dev/null
+++ b/libs/libfdt/rules.mk
@@ -0,0 +1,37 @@
+# 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.
+#
+
+LOCAL_DIR := $(GET_LOCAL_DIR)
+
+MODULE := $(LOCAL_DIR)
+
+SRC_DIR := packages/modules/Virtualization/libs/libfdt
+
+MODULE_SRCS := $(SRC_DIR)/src/lib.rs
+
+MODULE_CRATE_NAME := libfdt
+
+MODULE_RUST_EDITION := 2021
+
+MODULE_LIBRARY_DEPS += \
+ external/dtc/libfdt \
+ packages/modules/Virtualization/libs/cstr \
+ packages/modules/Virtualization/libs/libfdt/bindgen \
+ $(call FIND_CRATE,zerocopy) \
+ $(call FIND_CRATE,static_assertions) \
+
+MODULE_RUST_USE_CLIPPY := true
+
+include make/library.mk
diff --git a/tests/hostside/Android.bp b/tests/hostside/Android.bp
index 48e369c..0966c20 100644
--- a/tests/hostside/Android.bp
+++ b/tests/hostside/Android.bp
@@ -2,14 +2,9 @@
default_applicable_licenses: ["Android-Apache-2.0"],
}
-java_test_host {
- name: "MicrodroidHostTestCases",
+java_defaults {
+ name: "MicrodroidHostTestCases.default",
srcs: ["java/**/*.java"],
- test_suites: [
- "cts",
- "general-tests",
- "pts",
- ],
libs: [
"androidx.annotation_annotation",
"tradefed",
@@ -21,27 +16,6 @@
"microdroid_payload_metadata",
],
per_testcase_directory: true,
- device_common_data: [
- ":MicrodroidTestApp",
- ":MicrodroidTestAppUpdated",
- ":microdroid_general_sepolicy.conf",
- ":test.com.android.virt.pem",
- ":test2.com.android.virt.pem",
- "java/**/goldens/dt_dump_*",
- ],
- data_native_bins: [
- "sepolicy-analyze",
- // For re-sign test
- "avbtool",
- "img2simg",
- "initrd_bootconfig",
- "lpmake",
- "lpunpack",
- "lz4",
- "sign_virt_apex",
- "simg2img",
- "dtc",
- ],
// java_test_host doesn't have data_native_libs but jni_libs can be used to put
// native modules under ./lib directory.
// This works because host tools have rpath (../lib and ./lib).
@@ -58,3 +32,78 @@
"libz",
],
}
+
+DEVICE_DATA = [
+ ":MicrodroidTestApp",
+ ":MicrodroidTestAppUpdated",
+ ":microdroid_general_sepolicy.conf",
+ ":test.com.android.virt.pem",
+ ":test2.com.android.virt.pem",
+ "java/**/goldens/dt_dump_*",
+]
+
+BINS = [
+ "sepolicy-analyze",
+ // For re-sign test
+ "avbtool",
+ "img2simg",
+ "initrd_bootconfig",
+ "lpmake",
+ "lpunpack",
+ "lz4",
+ "sign_virt_apex",
+ "simg2img",
+ "dtc",
+]
+
+java_test_host {
+ name: "MicrodroidHostTestCases",
+ defaults: ["MicrodroidHostTestCases.default"],
+ test_config: "AndroidTest.xml",
+ test_suites: [
+ "general-tests",
+ "pts",
+ ],
+ device_common_data: DEVICE_DATA,
+ data_native_bins: BINS,
+}
+
+java_test_host {
+ name: "MicrodroidHostTestCases.CTS",
+ defaults: ["MicrodroidHostTestCases.default"],
+ test_config: ":MicrodroidHostTestCases.CTS.config",
+ test_suites: ["cts"],
+ device_common_data: DEVICE_DATA,
+ data_native_bins: BINS,
+}
+
+java_test_host {
+ name: "MicrodroidHostTestCases.VTS",
+ defaults: ["MicrodroidHostTestCases.default"],
+ test_config: ":MicrodroidHostTestCases.VTS.config",
+ test_suites: ["vts"],
+ device_common_data: DEVICE_DATA,
+ data_native_bins: BINS,
+}
+
+genrule {
+ name: "MicrodroidHostTestCases.CTS.config",
+ srcs: ["AndroidTest.xml"],
+ out: ["out.xml"],
+ cmd: "sed " +
+ "-e 's/<!-- PLACEHOLDER_FOR_ANNOTATION -->/" +
+ "<option name=\"include-annotation\" value=\"com.android.compatibility.common.util.CddTest\" \\/>/' " +
+ "-e 's/MicrodroidHostTestCases.jar/MicrodroidHostTestCases.CTS.jar/' " +
+ "$(in) > $(out)",
+}
+
+genrule {
+ name: "MicrodroidHostTestCases.VTS.config",
+ srcs: ["AndroidTest.xml"],
+ out: ["out.xml"],
+ cmd: "sed " +
+ "-e 's/<!-- PLACEHOLDER_FOR_ANNOTATION -->/" +
+ "<option name=\"include-annotation\" value=\"com.android.compatibility.common.util.VsrTest\" \\/>/' " +
+ "-e 's/MicrodroidHostTestCases.jar/MicrodroidHostTestCases.VTS.jar/' " +
+ "$(in) > $(out)",
+}
diff --git a/tests/hostside/AndroidTest.xml b/tests/hostside/AndroidTest.xml
index f77def3..c277865 100644
--- a/tests/hostside/AndroidTest.xml
+++ b/tests/hostside/AndroidTest.xml
@@ -32,4 +32,6 @@
<!-- Controller that will skip the module if a native bridge situation is detected -->
<!-- For example: module wants to run arm and device is x86 -->
<object type="module_controller" class="com.android.tradefed.testtype.suite.module.NativeBridgeModuleController" />
+
+ <!-- PLACEHOLDER_FOR_ANNOTATION -->
</configuration>
diff --git a/tests/vts/Android.bp b/tests/vts/Android.bp
new file mode 100644
index 0000000..c8e2523
--- /dev/null
+++ b/tests/vts/Android.bp
@@ -0,0 +1,36 @@
+prebuilt_etc {
+ name: "vts_libavf_test_kernel",
+ filename: "rialto.bin",
+ src: ":empty_file",
+ target: {
+ android_arm64: {
+ src: ":rialto_signed",
+ },
+ },
+ installable: false,
+ visibility: ["//visibility:private"],
+}
+
+rust_test {
+ name: "vts_libavf_test",
+ crate_name: "vts_libavf_test",
+ srcs: ["src/vts_libavf_test.rs"],
+ rustlibs: [
+ "libanyhow",
+ "libavf_bindgen",
+ "libciborium",
+ "liblog_rust",
+ "libhypervisor_props",
+ "libscopeguard",
+ "libservice_vm_comm",
+ "libvsock",
+ ],
+ shared_libs: ["libavf"],
+ test_suites: [
+ "general-tests",
+ "vts",
+ ],
+ data: [":vts_libavf_test_kernel"],
+ test_config: "AndroidTest.xml",
+ compile_multilib: "first",
+}
diff --git a/tests/vts/AndroidTest.xml b/tests/vts/AndroidTest.xml
new file mode 100644
index 0000000..75c8d31
--- /dev/null
+++ b/tests/vts/AndroidTest.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+<configuration description="Runs vts_libavf_test.">
+ <option name="test-suite-tag" value="vts" />
+
+ <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer" />
+
+ <target_preparer class="com.android.tradefed.targetprep.PushFilePreparer">
+ <option name="cleanup" value="true" />
+ <option name="push" value="vts_libavf_test->/data/local/tmp/vts_libavf_test" />
+ <option name="push" value="rialto.bin->/data/local/tmp/rialto.bin" />
+ </target_preparer>
+
+ <object type="module_controller" class="com.android.tradefed.testtype.suite.module.ArchModuleController">
+ <option name="arch" value="arm64" />
+ </object>
+
+ <test class="com.android.tradefed.testtype.rust.RustBinaryTest" >
+ <option name="test-device-path" value="/data/local/tmp" />
+ <option name="module-name" value="vts_libavf_test" />
+ <!-- rialto uses a fixed port number for the host, can't run two tests at the same time -->
+ <option name="native-test-flag" value="--test-threads=1" />
+ </test>
+</configuration>
diff --git a/tests/vts/src/vts_libavf_test.rs b/tests/vts/src/vts_libavf_test.rs
new file mode 100644
index 0000000..e30c175
--- /dev/null
+++ b/tests/vts/src/vts_libavf_test.rs
@@ -0,0 +1,196 @@
+// Copyright 2024 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//! Tests running a VM with LLNDK
+
+use anyhow::{bail, ensure, Context, Result};
+use log::info;
+use std::ffi::CStr;
+use std::fs::File;
+use std::io::{self, BufWriter, Write};
+use std::os::fd::IntoRawFd;
+use std::time::{Duration, Instant};
+use vsock::{VsockListener, VsockStream, VMADDR_CID_HOST};
+
+use avf_bindgen::*;
+use service_vm_comm::{Request, Response, ServiceVmRequest, VmType};
+
+const VM_MEMORY_MB: i32 = 16;
+const WRITE_BUFFER_CAPACITY: usize = 512;
+
+const LISTEN_TIMEOUT: Duration = Duration::from_secs(10);
+const READ_TIMEOUT: Duration = Duration::from_secs(10);
+const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
+const STOP_TIMEOUT: timespec = timespec { tv_sec: 10, tv_nsec: 0 };
+
+/// Processes the request in the service VM.
+fn process_request(vsock_stream: &mut VsockStream, request: Request) -> Result<Response> {
+ write_request(vsock_stream, &ServiceVmRequest::Process(request))?;
+ read_response(vsock_stream)
+}
+
+/// Sends the request to the service VM.
+fn write_request(vsock_stream: &mut VsockStream, request: &ServiceVmRequest) -> Result<()> {
+ let mut buffer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, vsock_stream);
+ ciborium::into_writer(request, &mut buffer)?;
+ buffer.flush().context("Failed to flush the buffer")?;
+ Ok(())
+}
+
+/// Reads the response from the service VM.
+fn read_response(vsock_stream: &mut VsockStream) -> Result<Response> {
+ let response: Response = ciborium::from_reader(vsock_stream)
+ .context("Failed to read the response from the service VM")?;
+ Ok(response)
+}
+
+fn listen_from_guest(port: u32) -> Result<VsockStream> {
+ let vsock_listener =
+ VsockListener::bind_with_cid_port(VMADDR_CID_HOST, port).context("Failed to bind vsock")?;
+ vsock_listener.set_nonblocking(true).context("Failed to set nonblocking")?;
+ let start_time = Instant::now();
+ loop {
+ if start_time.elapsed() >= LISTEN_TIMEOUT {
+ bail!("Timeout while listening");
+ }
+ match vsock_listener.accept() {
+ Ok((vsock_stream, _peer_addr)) => return Ok(vsock_stream),
+ Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
+ std::thread::sleep(Duration::from_millis(100));
+ }
+ Err(e) => bail!("Failed to listen: {e:?}"),
+ }
+ }
+}
+
+fn run_rialto(protected_vm: bool) -> Result<()> {
+ let kernel_file =
+ File::open("/data/local/tmp/rialto.bin").context("Failed to open kernel file")?;
+ let kernel_fd = kernel_file.into_raw_fd();
+
+ // SAFETY: AVirtualMachineRawConfig_create() isn't unsafe but rust_bindgen forces it to be seen
+ // as unsafe
+ let config = unsafe { AVirtualMachineRawConfig_create() };
+
+ info!("raw config created");
+
+ // SAFETY: config is the only reference to a valid object
+ unsafe {
+ AVirtualMachineRawConfig_setName(
+ config,
+ CStr::from_bytes_with_nul(b"vts_libavf_test_rialto\0").unwrap().as_ptr(),
+ );
+ AVirtualMachineRawConfig_setKernel(config, kernel_fd);
+ AVirtualMachineRawConfig_setProtectedVm(config, protected_vm);
+ AVirtualMachineRawConfig_setMemoryMiB(config, VM_MEMORY_MB);
+ }
+
+ let mut vm = std::ptr::null_mut();
+ let mut service = std::ptr::null_mut();
+
+ ensure!(
+ // SAFETY: &mut service is a valid pointer to *AVirtualizationService
+ unsafe { AVirtualizationService_create(&mut service, false) } == 0,
+ "AVirtualizationService_create failed"
+ );
+
+ scopeguard::defer! {
+ // SAFETY: service is a valid pointer to AVirtualizationService
+ unsafe { AVirtualizationService_destroy(service); }
+ }
+
+ ensure!(
+ // SAFETY: &mut vm is a valid pointer to *AVirtualMachine
+ unsafe {
+ AVirtualMachine_createRaw(
+ service, config, -1, // console_in
+ -1, // console_out
+ -1, // log
+ &mut vm,
+ )
+ } == 0,
+ "AVirtualMachine_createRaw failed"
+ );
+
+ scopeguard::defer! {
+ // SAFETY: vm is a valid pointer to AVirtualMachine
+ unsafe { AVirtualMachine_destroy(vm); }
+ }
+
+ info!("vm created");
+
+ let vm_type = if protected_vm { VmType::ProtectedVm } else { VmType::NonProtectedVm };
+
+ let listener_thread = std::thread::spawn(move || listen_from_guest(vm_type.port()));
+
+ // SAFETY: vm is the only reference to a valid object
+ unsafe {
+ AVirtualMachine_start(vm);
+ }
+
+ info!("VM started");
+
+ let mut vsock_stream = listener_thread.join().unwrap()?;
+ vsock_stream.set_read_timeout(Some(READ_TIMEOUT))?;
+ vsock_stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
+
+ info!("client connected");
+
+ let request_data = vec![1, 2, 3, 4, 5];
+ let expected_data = vec![5, 4, 3, 2, 1];
+ let response = process_request(&mut vsock_stream, Request::Reverse(request_data))
+ .context("Failed to process request")?;
+ let Response::Reverse(reversed_data) = response else {
+ bail!("Expected Response::Reverse but was {response:?}");
+ };
+ ensure!(reversed_data == expected_data, "Expected {expected_data:?} but was {reversed_data:?}");
+
+ info!("request processed");
+
+ write_request(&mut vsock_stream, &ServiceVmRequest::Shutdown)
+ .context("Failed to send shutdown")?;
+
+ info!("shutdown sent");
+
+ let mut stop_reason = AVirtualMachineStopReason::AVIRTUAL_MACHINE_UNRECOGNISED;
+ ensure!(
+ // SAFETY: vm is the only reference to a valid object
+ unsafe { AVirtualMachine_waitForStop(vm, &STOP_TIMEOUT, &mut stop_reason) },
+ "AVirtualMachine_waitForStop failed"
+ );
+
+ info!("stopped");
+
+ Ok(())
+}
+
+#[test]
+fn test_run_rialto_protected() -> Result<()> {
+ if hypervisor_props::is_protected_vm_supported()? {
+ run_rialto(true /* protected_vm */)
+ } else {
+ info!("pVMs are not supported on device. skipping test");
+ Ok(())
+ }
+}
+
+#[test]
+fn test_run_rialto_non_protected() -> Result<()> {
+ if hypervisor_props::is_vm_supported()? {
+ run_rialto(false /* protected_vm */)
+ } else {
+ info!("non-pVMs are not supported on device. skipping test");
+ Ok(())
+ }
+}