diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml
index 78e71579..0318d083 100644
--- a/.github/workflows/checks.yml
+++ b/.github/workflows/checks.yml
@@ -33,3 +33,9 @@ jobs:
- name: Use fmt() instead of string concatenation
run: python3 scripts/ci/check_string_concat.py
+
+ - name: Icons are vector drawables
+ run: python3 scripts/ci/check_vector_icons.py
+
+ - name: No signature over the parameter limit
+ run: python3 scripts/ci/check_arity.py
diff --git a/.gitignore b/.gitignore
index 2625919c..478500f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,7 @@ local.properties
# Terminal font: build.sh fetches the ~2.3MB Maple Mono NL NF TTF on first
# build; only the OFL license text under app/src/main/assets/fonts is committed.
/app/src/main/assets/fonts/*.ttf
+
+# Python bytecode from the scripts/ci checks.
+__pycache__/
+*.pyc
diff --git a/ADDITIONAL-PERMISSIONS b/ADDITIONAL-PERMISSIONS
new file mode 100644
index 00000000..987ba260
--- /dev/null
+++ b/ADDITIONAL-PERMISSIONS
@@ -0,0 +1,61 @@
+Additional permissions under GNU GPL version 3, section 7
+=========================================================
+
+These additional permissions apply to the parts of this work copyrighted by
+the Droid-VM organization and by contributors to this project. They do not
+apply to material copyrighted by others -- in particular, material inherited
+from the upstream project this repository is derived from, which remains under
+its own terms and is not affected by anything below.
+
+
+1. Upstream contribution
+------------------------
+
+You may modify this material and distribute the result under the license terms
+that an Upstream Project requires of contributions to it, for the sole purpose
+of having that material included in the Upstream Project's official repository.
+
+This permission takes effect only for material that the Upstream Project
+accepts and publishes. Proposing material to an Upstream Project -- opening a
+merge request, posting a patch, or any other act of submission -- does not by
+itself place that material under any license other than the GNU GPL. Material
+that is not accepted remains under the GNU GPL alone.
+
+Once an Upstream Project has published material under its own license, that
+license governs the copy the Upstream Project published. Nothing in this
+document restricts what anyone may do with that copy.
+
+This permission does not authorise distribution under any other license for any
+other purpose. In particular, it does not authorise distributing this material
+under a permissive license to the public at large, to a fork, or to a
+redistributor, whether or not an Upstream Project was also asked to take it.
+
+
+2. Upstream Projects
+--------------------
+
+"Upstream Project" means one of the following, and no others:
+
+
+
+An Upstream Project's "official repository" is the repository named above, or a
+repository that project's own maintainers designate as its successor. A fork,
+a mirror, a vendored copy, and a redistribution are not official repositories.
+
+
+3. Notes
+--------
+
+Under GPL version 3 section 7, a recipient may remove these additional
+permissions from a copy they convey. Doing so does not affect the terms on
+which the permissions are offered here, and does not withdraw them from anyone
+who received them.
+
+Adding permissions on top of the GNU GPL does not restrict any freedom the GPL
+grants. Every recipient keeps the full GPL grant regardless of whether they use
+anything in this document.
+
+Contributions to this project are made under this project's license, which
+includes these additional permissions. A contributor therefore does not need to
+sign a separate agreement for their contribution to be eligible for upstream
+submission under section 1. See CONTRIBUTING.md.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..903a743a
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,32 @@
+# Contributing
+
+## Sign-off
+
+Every commit must carry a `Signed-off-by:` line certifying the
+[Developer Certificate of Origin 1.1](https://developercertificate.org/):
+
+ git commit -s
+
+## What signing off means here
+
+The DCO asks you to certify that you have the right to submit your work under
+the license this project uses. For this project that license is the GNU GPL
+**plus the additional permissions in `ADDITIONAL-PERMISSIONS`**, so a
+contribution made under it carries those permissions too.
+
+That is deliberate, and it is why this project asks for a DCO rather than a
+CLA. The additional permissions let anyone relicense material from this project
+in order to get it accepted into the upstream project it belongs in. If
+contributions did not carry those permissions, every contribution would become
+a piece of the tree that could never be sent upstream, and the permission would
+quietly stop meaning anything as the project grew.
+
+You keep your copyright. Nothing is assigned to anyone.
+
+## Do not sign off on someone else's license
+
+If you are contributing material you did not write -- code ported from another
+project, a header copied from a kernel, a function translated from elsewhere --
+say so in the file and keep that material under its own license. Do not put a
+project SPDX tag on it. Several files in these repositories are in exactly that
+position and are marked accordingly.
diff --git a/LICENSING.md b/LICENSING.md
new file mode 100644
index 00000000..bedb1659
--- /dev/null
+++ b/LICENSING.md
@@ -0,0 +1,31 @@
+# Licensing
+
+This repository holds two kinds of material and they are licensed differently.
+
+## Material inherited from upstream
+
+
+
+Every file that came from an upstream project stays under that project's
+license. Nothing here relicenses it, and modifications to those files do not
+relicense them either — a patched upstream file is still an upstream file.
+
+## Material written for DroidVM
+
+Files carrying `SPDX-License-Identifier: GPL-3.0-or-later` are DroidVM work
+and are licensed under the GNU GPL, version 3 or later, **with the
+additional permissions in `ADDITIONAL-PERMISSIONS`**.
+
+Those permissions exist so this work can go upstream. They let anyone
+relicense it under the terms an upstream project requires, for the purpose of
+getting it merged there — and only for that purpose. Once upstream publishes
+it, upstream's license governs that copy.
+
+## Third-party material that is neither
+
+None. The app is DroidVM work throughout; `app/src/main/java/cn/classfun/droidvm`
+is the only source tree and nothing is vendored into it.
+
+## Contributing
+
+See `CONTRIBUTING.md`. Sign-off is required; there is no CLA.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index aac83d6e..e0f51c6d 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -5,6 +5,12 @@ import java.security.MessageDigest
plugins {
alias(libs.plugins.android.application)
+ // Compose is here for one screen: the Markdown notes editor and the cards that render what
+ // it wrote, which come from a Compose-only library. Everything else stays Java and Views.
+ // Kotlin itself needs no plugin -- AGP 9 compiles it out of the box and already owns the
+ // "kotlin" extension -- so only the Compose compiler plugin is applied, pinned to the same
+ // Kotlin version AGP carries.
+ alias(libs.plugins.kotlin.compose)
}
fun runGit(vararg args: String): String {
@@ -94,9 +100,32 @@ android {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
+ kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
+ }
+ }
buildFeatures {
aidl = true
buildConfig = true
+ compose = true
+ }
+ sourceSets {
+ getByName("main") {
+ // Third-party source kept in the tree rather than pulled as an artifact, in its own
+ // root so that "not ours, and under its own licence" is structural. See its README.
+ kotlin.srcDir("src/main/vendor")
+ }
+ }
+ testOptions {
+ unitTests {
+ // Lets a unit test cover a class that logs. The alternative -- keeping every testable
+ // class free of android.util.Log -- stopped being tenable at the H.264 side channel,
+ // whose whole subject is a socket and a thread outliving the object that owned them,
+ // and which says so out loud when they do. Nothing here asserts on a stub's return
+ // value; the stubs are only there so the class under test can be built at all.
+ isReturnDefaultValues = true
+ }
}
packaging {
jniLibs {
@@ -329,10 +358,18 @@ dependencies {
implementation(libs.annotation.jvm)
implementation(libs.appcompat)
implementation(libs.auto.service.annotations)
+ implementation(platform(libs.compose.bom))
+ implementation(libs.compose.animation)
+ implementation(libs.compose.foundation)
+ implementation(libs.compose.ui)
+ implementation(libs.compose.material3)
implementation(libs.constraintlayout)
implementation(libs.libsu.core)
implementation(libs.libsu.nio)
implementation(libs.libsu.service)
+ // The renderer itself lives in src/main/vendor; these are what it needs.
+ implementation(libs.markdown.parser)
+ implementation(libs.kotlinx.collections.immutable)
implementation(libs.material)
implementation(libs.okhttp3)
implementation(libs.snakeyaml)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 195153a9..ed6731d6 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,19 @@
A Gunyah VM's memory has to be supplied as 2MB huge pages — each one a single contiguous block. The system, though, normally hands out memory in 4KB small pages, many of which can never be moved once placed.
+The longer the phone runs, the more those small pages scatter, and the fewer places remain where a full 2MB block can still be assembled. By the time a VM boots there are often not enough huge pages left, and it fails with Out of memory.
+Right after boot, while memory is still whole, it takes and holds a batch of 2MB huge pages — the amount is adjustable on the management screen. A booting VM is served straight from that reserve, and when the VM shuts down the pages are recovered into the pool for reuse.
+This module has to load very early in boot, before memory fragments, so it is installed separately as a Magisk / KernelSU / APatch module rather than being loaded by this app.
+Gunyah VM 的内存必须以 2MB 的大页来供应,每一个都是一整块连续的内存。但系统平常是以 4KB 的小页在分配内存,而且其中不少一旦放下就不能再搬动。
+开机越久,这些小页越是四处散落,能凑出完整 2MB 的位置就越来越少;到 VM 要开机时,往往已经凑不齐足够的大页,只能失败并显示 Out of memory。
+趁刚开机、内存还完整的时候,先抢下一批 2MB 大页并保留起来(数量可在管理页调整)。VM 开机时直接从保留池供应;VM 关机归还后,页面回收回池中,重复使用。
+这个模块必须在开机很早期就加载(否则内存已经碎了),所以以 Magisk / KernelSU / APatch 模块的形式另外安装,而不是由本 App 加载。
+Gunyah VM 的記憶體必須以 2MB 的大頁來供應,每一個都是一整塊連續的記憶體。但系統平常是以 4KB 的小頁在配置記憶體,而且其中不少一旦放下就不能再搬動。
+開機越久,這些小頁越是四處散落,能湊出完整 2MB 的位置就越來越少;到 VM 要開機時,往往已經湊不齊足夠的大頁,只能失敗並顯示 Out of memory。
+趁剛開機、記憶體還完整的時候,先搶下一批 2MB 大頁並保留起來(數量可在管理頁調整)。VM 開機時直接從保留池供應;VM 關機歸還後,頁面回收回池中,重複使用。
+這個模組必須在開機很早期就載入(否則記憶體已經碎了),所以以 Magisk / KernelSU / APatch 模組的形式另外安裝,而不是由本 App 載入。
+AAudio names a device with an integer the platform hands out per connection: unplug a headset + * and plug it back in and the number is different, though it is plainly the same headset. A VM + * pinned to an endpoint therefore cannot hold on to the number -- it has to hold the name and look + * the number up again, every time it opens a stream.
+ * + *Only Java can enumerate the devices: AAudio has no API for it and {@link AudioManager} is not + * reachable from the backend, which is a native process running unprivileged. So the list is + * written out here and re-read there. The matching happens on the crosvm side rather than the id + * being pushed to it, so a device coming back needs no round trip -- the file changes, and the + * name the backend is holding is still the same name.
+ */ +public final class HostAudioTable { + private static final String TAG = "HostAudioTable"; + + /** Where the table lives. One per daemon, not per VM: it describes the host, not a guest. */ + public static final String PATH = pathJoin(DATA_DIR, "run", "audio_devices"); + + private static @Nullable HostAudioTable instance; + + private final Context context; + private final AudioDeviceCallback callback; + + private HostAudioTable(@NonNull Context context) { + this.context = context; + this.callback = new AudioDeviceCallback() { + @Override + public void onAudioDevicesAdded(AudioDeviceInfo[] added) { + write(); + } + + @Override + public void onAudioDevicesRemoved(AudioDeviceInfo[] removed) { + write(); + } + }; + } + + /** + * Starts publishing, and keeps publishing until the daemon exits. Safe to call more than + * once; only the first call does anything. + */ + public static synchronized void start(@Nullable Context context) { + try { + startOrThrow(context); + } catch (Throwable t) { + // Never take the daemon down over this. Without the table a VM pinned to an endpoint + // falls back to the platform's routing, which is a worse configuration than the user + // asked for; a daemon that will not start is no configuration at all. + Log.w(TAG, "failed to start publishing host audio endpoints", t); + } + } + + private static void startOrThrow(@Nullable Context context) { + if (instance != null || context == null) return; + var am = context.getSystemService(AudioManager.class); + if (am == null) { + Log.w(TAG, "AudioManager unavailable; host audio endpoints will not be published"); + return; + } + var table = new HostAudioTable(context); + // Its own thread, rather than Looper.getMainLooper(): the daemon is not an app process + // and has no main looper, so asking for one returns null and constructing a Handler on + // it throws. Writing the table is a few hundred bytes and happens only when something is + // plugged or unplugged, so a thread of its own costs nothing. + var thread = new HandlerThread("HostAudioTable"); + thread.start(); + am.registerAudioDeviceCallback(table.callback, new Handler(thread.getLooper())); + table.write(); + instance = table; + Log.i(TAG, fmt("publishing host audio endpoints to %s", PATH)); + } + + /** + * Writes the current endpoints. + * + *Lines are {@code
This runs on the device-callback thread, where an uncaught exception takes the whole + * daemon down with it -- which is how a failure to describe the audio devices came to stop + * VMs from starting at all.
+ */ + private void write() { + try { + writeOrThrow(); + } catch (Throwable t) { + Log.w(TAG, "failed to publish host audio endpoints", t); + } + } + + private void writeOrThrow() { + var text = new StringBuilder(); + int lines = appendAll(text, false) + appendAll(text, true); + + var target = new File(PATH); + var staging = new File(fmt("%s.new", PATH)); + try { + var parent = target.getParentFile(); + if (parent != null && !parent.isDirectory() && !parent.mkdirs()) { + Log.w(TAG, fmt("cannot create %s", parent)); + return; + } + try (var out = new FileOutputStream(staging)) { + out.write(text.toString().getBytes(StandardCharsets.UTF_8)); + out.getFD().sync(); + } + // The backend runs as this app's uid, so the default mode already lets it read. + if (!staging.renameTo(target)) { + Log.w(TAG, "failed to replace the host audio table"); + //noinspection ResultOfMethodCallIgnored + staging.delete(); + return; + } + Log.i(TAG, fmt("host audio endpoints published: %d", lines)); + } catch (IOException e) { + Log.w(TAG, "failed to write the host audio table", e); + } + } + + private int appendAll(@NonNull StringBuilder text, boolean input) { + // The platform's own routing, as an ordinary row against AAUDIO_DEVICE_UNSPECIFIED. + // Listing it means "follow the platform" resolves through the same lookup as everything + // else, instead of being an absence that every reader has to recognise separately. + // Rate and channels are left at 0 -- following the platform means whatever it routes to + // today, and naming a format for it would be describing one particular device. The kind + // is knowable regardless: the direction decides it. + text.append(fmt("%d\t%s\t%s\t0\t0\t%d\n", HostAudioDevices.DEVICE_UNSPECIFIED, + input ? "in" : "out", HostAudioDevices.SYSTEM_DEFAULT_KEY, input ? 6 : 1)); + // Deliberately not HostAudioDevices.list: that builds a label for the picker, and a label + // needs the app's string resources, which the daemon's context does not have. + var keys = new java.util.ArrayListAgent control channels can carry credentials on their input side and protocol chatter + * on their output side. They still need the small in-memory ring buffer for normal stream + * handling, but have no useful history to retain on disk.
+ */ + public synchronized void setPersistentLogEnabled(boolean enabled) { + persistentLogEnabled = enabled; + if (!enabled && logWriter != null) { + try { + logWriter.close(); + } catch (Exception e) { + Log.w(TAG, "Failed to close disabled console log", e); + } + logWriter = null; + } + } + public void clear() { buffer.clear(); disableSave = false; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/FDPipeConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/FDPipeConsoleStream.java index 3525a905..d1ed9831 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/FDPipeConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/FDPipeConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import android.os.ParcelFileDescriptor; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/FDSocketConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/FDSocketConsoleStream.java index 7e4fe5be..0dd9d22c 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/FDSocketConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/FDSocketConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/InputConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/InputConsoleStream.java index bb04720e..15d3fd4a 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/InputConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/InputConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/LocalSocketConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/LocalSocketConsoleStream.java index 5aa42749..dc51f381 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/LocalSocketConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/LocalSocketConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import android.net.LocalSocket; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/SimpleConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/SimpleConsoleStream.java index 1f268d98..6b63881c 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/SimpleConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/SimpleConsoleStream.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.console; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/display/DaemonSystemContext.java b/app/src/main/java/cn/classfun/droidvm/daemon/display/DaemonSystemContext.java index e5e4c1f0..c6bc5667 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/display/DaemonSystemContext.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/display/DaemonSystemContext.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.display; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/display/NativeDisplayBinder.java b/app/src/main/java/cn/classfun/droidvm/daemon/display/NativeDisplayBinder.java index 6b866490..649763ce 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/display/NativeDisplayBinder.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/display/NativeDisplayBinder.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.display; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -15,6 +18,7 @@ import cn.classfun.droidvm.daemon.server.ServerContext; import cn.classfun.droidvm.display.INativeDisplayRootService; import cn.classfun.droidvm.lib.store.vm.NativeDisplay; +import cn.classfun.droidvm.lib.store.vm.VMState; /** * Daemon-hosted native-display broker. The daemon already runs as root, so it does both jobs the @@ -23,8 +27,9 @@ *Asked of the daemon rather than enumerated in the app, because the two addresses that must + * not appear -- a pseudo-bridged guest's IP parked on the uplink, and the host-route-only shape + * pbridge parks it in -- are netlink details no unprivileged interface enumeration can see. See + * {@link HostAddressScan#list()} for the whole policy.
+ */ +@AutoService(RequestHandler.class) +public final class ListHostAddressesHandler extends RequestHandler { + @NonNull + @Override + public String getName() { + return "network_list_host_addresses"; + } + + @Override + public void handle(@NonNull ClientRequest request) throws Exception { + request.res().put("data", HostAddressScan.list()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListInterfacesHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListInterfacesHandler.java index 1bda4ec2..a4f11da1 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListInterfacesHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListInterfacesHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListUplinksHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListUplinksHandler.java index 98c28625..772d2d25 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListUplinksHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListUplinksHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ModifyHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ModifyHandler.java index e3569534..8cbf9fa3 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ModifyHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ModifyHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdReleaseHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdReleaseHandler.java index 93bc9245..a13c8f49 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdReleaseHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdReleaseHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdRenewHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdRenewHandler.java index 1a253a94..14f77574 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdRenewHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/PdRenewHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveAddressHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveAddressHandler.java index e4b9c623..62a4bc21 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveAddressHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveAddressHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveInterfaceHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveInterfaceHandler.java index be458a26..c98e0f02 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveInterfaceHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/RemoveInterfaceHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StartHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StartHandler.java index bec5959d..e0e920a6 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StartHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StartHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StatusHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StatusHandler.java index 712e10ee..ff0cb6cd 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StatusHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StatusHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StopHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StopHandler.java index fe81b800..8fef1942 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StopHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/StopHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ToolLogHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ToolLogHandler.java index bbd4399e..86dd9777 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ToolLogHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ToolLogHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/BootScanHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/BootScanHandler.java index 1d09eb7d..07fd26cc 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/BootScanHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/BootScanHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleClearHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleClearHandler.java index e75e2d68..a966ff70 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleClearHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleClearHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleHistoryHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleHistoryHandler.java index 2a8460c1..70c8bbb0 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleHistoryHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleHistoryHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleInfoHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleInfoHandler.java index d3ab3bae..fb571fa6 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleInfoHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleInfoHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleListHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleListHandler.java index 7eceecba..ccbbeb51 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleListHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleListHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleWriteHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleWriteHandler.java index c3694081..2ce19763 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleWriteHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ConsoleWriteHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ControlHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ControlHandler.java index d5616a46..82a8d62e 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ControlHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ControlHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/CreateHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/CreateHandler.java index 6373cd38..3a97efed 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/CreateHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/CreateHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DeleteHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DeleteHandler.java index 4ca0bd72..b10cb148 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DeleteHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DeleteHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -27,10 +30,16 @@ public void handle(@NonNull ClientRequest request) throws Exception { throw new RequestException("missing vm_id"); var vms = request.getContext().getVMs(); var inst = vms.findById(vmId); - if (inst == null) - throw new RequestException(fmt("VM not found: %s", vmId)); + // A VM the daemon never managed (created in the app but never started) has nothing to + // stop and nothing to remove: deleting it is a no-op, not an error - the app deletes + // its disks on our word that no process of ours holds them. + if (inst == null) { + request.res().put("existed", false); + return; + } if (inst.getState() != VMState.STOPPED && inst.stop()) throw new RequestException(fmt("Failed to stop VM: %s", vmId)); vms.removeById(inst.getId()); + request.res().put("existed", true); } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DiskCompatHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DiskCompatHandler.java deleted file mode 100644 index d6805942..00000000 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DiskCompatHandler.java +++ /dev/null @@ -1,42 +0,0 @@ -package cn.classfun.droidvm.daemon.ipc.vm; - -import androidx.annotation.NonNull; - -import com.google.auto.service.AutoService; - -import org.json.JSONArray; - -import cn.classfun.droidvm.daemon.server.ClientRequest; -import cn.classfun.droidvm.daemon.server.RequestHandler; -import cn.classfun.droidvm.daemon.vm.BootPlan; - -/** - * Reports which of the given disk images use qcow2 features the crosvm - * backend can't read (zlib-compressed clusters) for the UI's pre-start - * guard: such an image boots to a dead end (vda I/O errors, no partition - * table, root device never appears). lbx already runs in the daemon for - * boot scans, and disk images are usually only readable here, so the check - * lives daemon-side. This is never used on the URL-analysis path. - */ -@AutoService(RequestHandler.class) -public final class DiskCompatHandler extends RequestHandler { - @NonNull - @Override - public String getName() { - return "disk_compat"; - } - - @Override - public void handle(@NonNull ClientRequest request) throws Exception { - var images = request.getParams().optJSONArray("images"); - var compressed = new JSONArray(); - if (images != null) { - for (int i = 0; i < images.length(); i++) { - var path = images.optString(i, ""); - if (!path.isEmpty() && BootPlan.hasCompressedClusters(path)) - compressed.put(path); - } - } - request.res().put("compressed", compressed); - } -} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DisplayAttachHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DisplayAttachHandler.java index 043f1dd5..e20c94e5 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DisplayAttachHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/DisplayAttachHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExistsHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExistsHandler.java index 2bd6c570..63fdea58 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExistsHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExistsHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExportHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExportHandler.java index dab0c132..43f13bce 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExportHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ExportHandler.java @@ -1,9 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; import com.google.auto.service.AutoService; +import java.io.IOException; + import cn.classfun.droidvm.daemon.server.ClientRequest; import cn.classfun.droidvm.daemon.server.RequestException; import cn.classfun.droidvm.daemon.server.RequestHandler; @@ -34,7 +39,15 @@ public void handle(@NonNull ClientRequest request) throws Exception { )); var store = request.getContext().getExportTaskStore(); var server = request.getClient().getServer(); - var task = new VMExportTask(server, params); + VMExportTask task; + try { + task = new VMExportTask(server, params); + } catch (IOException e) { + // Building the task walks the disks' backing chains, and the reason one cannot be + // walked (which image, which missing base) is the whole answer for the caller - + // a plain exception here would reach it as "internal error". + throw new RequestException(e.getMessage()); + } store.put(task.taskId, task); task.startAsync(); request.res().put("task_id", task.taskId.toString()); diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/GetHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/GetHandler.java index 44be4feb..f7ae0c49 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/GetHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/GetHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ImportHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ImportHandler.java index caac7fe4..38829344 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ImportHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ImportHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/InputHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/InputHandler.java index 15f771e4..04e69214 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/InputHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/InputHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -16,7 +19,9 @@ * Forwards native-display input from the UI to the per-VM crosvm process. The daemon is the only * listener on crosvm's --input sockets (it pre-binds them before exec'ing crosvm), so it is the * only process that can deliver evdev to the guest; the UI sends bytes here instead of writing a - * socket directly. Params: vm_id, channel (NativeDisplay constants), data (base64 evdev records). + * socket directly. Params: vm_id, screen (the screen the sending console shows, which picks + * between two screens' absolute devices; ignored by the VM-wide keyboard and relative pointer), + * channel (NativeDisplay constants), data (base64 evdev records). */ @AutoService(RequestHandler.class) public final class InputHandler extends RequestHandler { @@ -32,13 +37,15 @@ public void handle(@NonNull ClientRequest request) throws Exception { var vmId = params.optString("vm_id", ""); if (vmId.isEmpty()) throw new RequestException("missing vm_id"); + var screenId = params.optString("screen", ""); var channel = params.optInt("channel", -1); var data = Base64.decode(params.optString("data", ""), Base64.NO_WRAP); var inst = request.getContext().getVMs().findById(vmId); if (inst == null) throw new RequestException(fmt("VM not found: %s", vmId)); // Report whether the bytes actually reached crosvm so the UI can tell a silent drop (peer - // not connected yet, bad channel, VM not running) from a real delivery. - request.res().put("delivered", inst.writeNativeInput(channel, data)); + // not connected yet, bad channel, no absolute device on that screen, VM not running) from + // a real delivery. + request.res().put("delivered", inst.writeNativeInput(screenId, channel, data)); } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ListHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ListHandler.java index 83eaf1e4..ef049838 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ListHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ListHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ModifyHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ModifyHandler.java index 8d7b9220..dd8e9ab6 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ModifyHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ModifyHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/RebootHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/RebootHandler.java index fb3af10a..1d131dd7 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/RebootHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/RebootHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ResumeHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ResumeHandler.java index dfd9991d..aca06f6d 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ResumeHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/ResumeHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StartHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StartHandler.java index 99922c35..b7bb2ccb 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StartHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StartHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StatusHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StatusHandler.java index 650af356..7aaaf686 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StatusHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StatusHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopAllHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopAllHandler.java index 305d8916..174f69c3 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopAllHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopAllHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopHandler.java index 06af6617..1a072c5b 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/StopHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/SuspendHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/SuspendHandler.java index a70a1c98..24d5b3a0 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/SuspendHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/SuspendHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/VncInfoHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/VncInfoHandler.java index 30c366e2..abd6e86c 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/VncInfoHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/vm/VncInfoHandler.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.ipc.vm; import androidx.annotation.NonNull; @@ -7,6 +10,8 @@ import cn.classfun.droidvm.daemon.server.ClientRequest; import cn.classfun.droidvm.daemon.server.RequestException; import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.lib.store.vm.DisplayExporter; +import cn.classfun.droidvm.lib.store.vm.VMScreenConfig; @AutoService(RequestHandler.class) public final class VncInfoHandler extends RequestHandler { @@ -25,13 +30,30 @@ public void handle(@NonNull ClientRequest request) throws Exception { var inst = request.getContext().getVMs().findById(vmId); if (inst == null) throw new RequestException("VM not found"); - if (!inst.item.optBoolean("vnc_enabled", false)) + // Which screen's server. A client that names none gets the first VNC-bound screen, which + // is the only one a single-screen VM has and the one its default view opens. + var screenId = params.optString("screen", ""); + VMScreenConfig screen = null; + for (var candidate : VMScreenConfig.listOf(inst.item)) { + if (!candidate.isEnabled() || candidate.getExporter() != DisplayExporter.VNC) continue; + if (screenId.isEmpty() || screenId.equals(candidate.id)) { + screen = candidate; + break; + } + } + if (screen == null) throw new RequestException("VNC is not enabled for this VM"); var res = request.res(); - var host = inst.item.optString("vnc_host", ""); + var host = screen.getVncHost(); + res.put("screen", screen.id); res.put("host", !host.isEmpty() ? host : "127.0.0.1"); - res.put("port", inst.item.optLong("vnc_port", -1)); - res.put("password", inst.item.optString("vnc_password", "")); + res.put("port", screen.getVncPort()); + res.put("password", screen.getVncPassword()); + // What this binding's transport ceiling permits. A permit and not a promise, which is why + // there is no port beside it any more: whether an encoder is actually standing there is + // answered on the RFB connection itself, by the capabilities rect, and a second port for + // the console to be told about is exactly what that change removed. + res.put("transport_cap", screen.getTransportCap().getToken()); // When VNC binds to the IPv4 wildcard, resolve the phone's own LAN // address here from the router watcher's filtered host-IP set, which // already drops pbridge offload-proxy addresses parked on the uplink. diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstance.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstance.java index 87715f4f..fccaf77f 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstance.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstance.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstanceStore.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstanceStore.java index e69a9975..cecfb4ff 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstanceStore.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstanceStore.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network; import static cn.classfun.droidvm.lib.store.network.NetworkState.RUNNING; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkWatchdog.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkWatchdog.java index 40c08c56..cd7e5ad4 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkWatchdog.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkWatchdog.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network; import android.util.Log; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BackendBase.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BackendBase.java index 1786d6c4..82c4c1bc 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BackendBase.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BackendBase.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeBackend.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeBackend.java index 522ec52d..a9bf3996 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeBackend.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeBackend.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeDhcp.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeDhcp.java index b9d5c41a..f7bf966d 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeDhcp.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/BridgeDhcp.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend; import static cn.classfun.droidvm.lib.Constants.DATA_DIR; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/DefaultRouterWatcher.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/DefaultRouterWatcher.java index c6b8e59b..38a2f5e2 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/DefaultRouterWatcher.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/DefaultRouterWatcher.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.network.backend; import static cn.classfun.droidvm.lib.utils.FileUtils.shellReadFile; @@ -40,17 +43,14 @@ public final class DefaultRouterWatcher { */ private static final int RULE_PRIORITY_BASE = 9000; /** - * Interface-name prefixes whose IPv4 addresses count as the phone's own - * reachable IPs for port-forward DNAT scoping: Wi-Fi, cellular, VPN, - * ethernet, and hotspot/USB/BT tethering. Passed to netbox, which also - * drops bridge devices and pbridge-offload addresses. Cellular names other - * than rmnet_data (ccmni, pdp_ip...) are intentionally not matched -- same - * assumption the iptables EXT_IFACES list already makes. + * The prefixes whose addresses count as the phone's own, for port-forward + * DNAT scoping. Passed to netbox, which also drops bridge devices and + * pbridge-offload addresses. Shared with {@link HostAddressScan}, which + * answers the same question for the VNC listen-address picker and applies + * the rest of the same policy in Java; the list lives there. */ - private static final ListBoth families, unlike {@link Netbox#hostIpv4}, and one shot rather than a watched set: this + * answers a picker that is open for a few seconds, not a firewall rule that has to follow the + * network. The policy is otherwise the same one, and lives here because the daemon is the only + * side that can apply it -- see the two exclusions below, neither of which + * {@code java.net.NetworkInterface} can see.
+ */ +public final class HostAddressScan { + private static final String TAG = "HostAddressScan"; + + /** + * Interface-name prefixes whose addresses count as the phone's own reachable IPs: Wi-Fi, + * cellular, VPN, ethernet, and hotspot/USB/BT tethering. Cellular names other than + * {@code rmnet_data} (ccmni, pdp_ip...) are intentionally not matched -- the same assumption + * the iptables EXT_IFACES list already makes. + * + *One list, two readers: the port-forward DNAT scoping this was written for (through + * netbox's own {@code host-ips} policy) and the VNC listen-address picker. They are asking the + * same question, so they must not answer it from two lists.
+ */ + public static final ListFour things are dropped, and the first two are the whole reason this is not done in the + * app process:
+ * + *Honoured by both backends. crosvm takes it in the same positional slot a direct-boot + * kernel would go in and opens it as an ordinary file, so there is nothing about the builtin + * path it is attached to -- which is what a comment here used to claim, while the command + * builder passed the builtin whatever this said.
+ */ @NonNull public final String firmware; /** Custom UEFI vars path; empty = builtin EDK2 vars. */ @@ -77,15 +87,33 @@ public final class BootPlan { /** Image mode: a pinned/override entry was not found, default used. */ public final boolean entryFallback; - private BootPlan( - boolean uefi, @NonNull String firmware, @NonNull String vars, - boolean varsEnabled, @NonNull String kernel, @NonNull String initrd, - @NonNull String cmdline, @Nullable String entryTitle, boolean entryFallback - ) { - this.uefi = uefi; + /** + * A UEFI plan: firmware and vars, and nothing to direct-boot. Empty paths mean the builtins. + */ + private BootPlan(@NonNull String firmware, @NonNull String vars, boolean varsEnabled) { + this.uefi = true; this.firmware = firmware; this.vars = vars; this.varsEnabled = varsEnabled; + this.kernel = ""; + this.initrd = ""; + this.cmdline = ""; + this.entryTitle = null; + this.entryFallback = false; + } + + /** + * A direct-boot plan: a kernel, an initrd and a cmdline, and no firmware. The entry fields are + * image mode's, and stay null/false for a manual or built-in-kernel boot. + */ + private BootPlan( + @NonNull String kernel, @NonNull String initrd, @NonNull String cmdline, + @Nullable String entryTitle, boolean entryFallback + ) { + this.uefi = false; + this.firmware = ""; + this.vars = ""; + this.varsEnabled = false; this.kernel = kernel; this.initrd = initrd; this.cmdline = cmdline; @@ -124,18 +152,13 @@ public static BootPlan resolve( return resolveBuiltin(config, boot); if (boot.getProtocol() == BootConfig.Protocol.UEFI) return new BootPlan( - true, boot.getUefiFirmware(), boot.getUefiVars(), - boot.isUefiVarsEnabled(), "", "", "", null, false - ); + boot.getUefiFirmware(), boot.getUefiVars(), boot.isUefiVarsEnabled()); if (boot.getLinuxSource() == BootConfig.LinuxSource.MANUAL) { var kernel = boot.getKernel(); // pre-boot{} configs stored the EDK2 path as the kernel if (kernel.equals(PATH_EDK2_FIRMWARE)) - return new BootPlan(true, "", "", false, "", "", "", null, false); - return new BootPlan( - false, "", "", false, kernel, boot.getInitrd(), - boot.getCmdline(), null, false - ); + return new BootPlan("", "", false); + return new BootPlan(kernel, boot.getInitrd(), boot.getCmdline(), null, false); } return resolveImage(config, boot, entryOverrideId); } @@ -153,8 +176,8 @@ public static BootPlan resolve( private static BootPlan resolveBuiltin( @NonNull VMConfig config, @NonNull BootConfig boot) { return new BootPlan( - false, "", "", false, PATH_BUILTIN_KERNEL, PATH_BUILTIN_INITRD, - builtinCmdline(config, boot), "DroidVM built-in kernel", false + PATH_BUILTIN_KERNEL, PATH_BUILTIN_INITRD, builtinCmdline(config, boot), + "DroidVM built-in kernel", false ); } @@ -225,7 +248,6 @@ else if (pinned != null) var initrd = new File(cacheDir, "initrd"); var title = optStr(entry, "title"); return new BootPlan( - false, "", "", false, new File(cacheDir, "kernel").getAbsolutePath(), initrd.exists() ? initrd.getAbsolutePath() : "", cmdline, @@ -263,24 +285,6 @@ public static JSONArray scanEntries(@NonNull String image) throws IOException { } } - /** - * Whether {@code image} stores zlib-compressed qcow2 clusters, which the - * crosvm backend cannot read: the guest gets I/O errors and an - * unreadable partition table, so every {@code root=} form hangs waiting - * for a root device that never appears. Runs {@code lbx compat --json}; - * any lbx failure returns {@code false} so a scan hiccup never blocks a - * start (a real boot would surface the problem anyway). - */ - public static boolean hasCompressedClusters(@NonNull String image) { - try { - var out = runLbx("compat", image, "--json").trim(); - return new JSONObject(out).optBoolean("compressed_clusters", false); - } catch (Exception e) { - Log.w(TAG, fmt("compat check failed for %s: %s", image, e.getMessage())); - return false; - } - } - @Nullable private static JSONObject matchEntry( @NonNull JSONArray entries, diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/PeripheralForegroundControl.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/PeripheralForegroundControl.java new file mode 100644 index 00000000..df69958c --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/PeripheralForegroundControl.java @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.vm; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import cn.classfun.droidvm.daemon.display.DaemonSystemContext; +import cn.classfun.droidvm.lib.peripheral.PeripheralForegroundService; +import cn.classfun.droidvm.lib.store.vm.VMPeripheralConfig; +import cn.classfun.droidvm.lib.store.vm.VMState; + +/** + * Keeps {@link PeripheralForegroundService} in step with what this daemon is running. + * + *Driven from the daemon rather than the app process, even though the service lives in the app. + * The daemon is the only party that knows when a VM actually starts -- a VM can be started over + * IPC with no UI open at all -- and it is the only one allowed to raise the service at that + * moment: an app calling {@code startForegroundService} from the background is refused, while + * {@code ActiveServices} exempts a root caller by app id, and the background-start check seeds + * itself from that same verdict. The service still runs in the app process under the app's uid, + * which is the uid whose capability the guest needs, so who asked for it does not change what it + * grants.
+ * + *Nothing here names a kind of peripheral: the mask comes from + * {@code PeripheralType.getForegroundServiceType}.
+ */ +final class PeripheralForegroundControl { + private static final String TAG = "PeripheralFgsControl"; + + /** Last mask handed to the service, so an unchanged state is not re-applied on every event. */ + private static int applied = 0; + + private PeripheralForegroundControl() { + } + + /** Recomputes from every instance in {@code store} and starts, re-types or stops the service. */ + static synchronized void refresh(@NonNull VMInstanceStore store) { + int wanted = 0; + try { + var mask = new int[1]; + store.forEach((id, instance) -> { + if (instance.getState() == VMState.STOPPED) return; + for (var peripheral : VMPeripheralConfig.listOf(instance.item)) { + var type = peripheral.getType(); + // A device the host cannot serve is not attached, so it needs nothing. + if (!type.isAvailable()) continue; + mask[0] |= type.getForegroundServiceType(); + } + }); + wanted = mask[0]; + } catch (Exception e) { + Log.w(TAG, "could not work out which peripherals are running", e); + return; + } + if (wanted == applied) return; + var context = DaemonSystemContext.get(); + if (context == null) { + // Without a Context there is no way to reach the service. Leave `applied` alone so a + // later call retries rather than believing it has already done this. + Log.w(TAG, "no system context; peripheral foreground service not updated"); + return; + } + Log.i(TAG, fmt("peripheral foreground service types 0x%s -> 0x%s", + Integer.toHexString(applied), Integer.toHexString(wanted))); + PeripheralForegroundService.apply(context, wanted); + applied = wanted; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/SerialPipe.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/SerialPipe.java index 935fc319..f0ffe455 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/SerialPipe.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/SerialPipe.java @@ -1,3 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. package cn.classfun.droidvm.daemon.vm; import java.io.Closeable; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/UsbAcmPool.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/UsbAcmPool.java new file mode 100644 index 00000000..7e595dd7 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/UsbAcmPool.java @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +package cn.classfun.droidvm.daemon.vm; + +import static cn.classfun.droidvm.lib.utils.FileUtils.deleteFile; +import static cn.classfun.droidvm.lib.utils.FileUtils.readFile; +import static cn.classfun.droidvm.lib.utils.FileUtils.writeFile; +import static cn.classfun.droidvm.lib.utils.RunUtils.runListQuiet; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; +import static cn.classfun.droidvm.lib.utils.ThreadUtils.threadSleep; + +import android.system.Os; +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import cn.classfun.droidvm.lib.store.base.DataItem; + +/** + * A fixed pool of CDC-ACM functions on the device's USB gadget, shared by every VM serial + * port with the USB_ACM backend. crosvm opens the pool member's {@code /dev/ttyGSn} with its + * {@code type=dev} serial; the external host enumerates one USB serial port per member + * (Windows usbser.sys / Linux cdc_acm, both in-box). + * + *Why a pool instead of one function per VM: adding or removing a gadget function only + * takes effect through a UDC rebind, and a rebind re-enumerates the whole gadget -- every + * other ACM port and a USB-cable adb connection drop with it. So the pool is built once, in + * one rebind, and VM starts/stops merely attach and release members -- zero rebinds, and the + * host's COM numbering stays stable (usbser remembers ports per interface). An idle member + * is just a quiet COM port on the host. Only a pool rebuild (size change, or the framework + * wiping the config on a USB mode switch) rebinds again.
+ * + *Android's init mounts configfs at {@code /config} with labels this root daemon can use, + * and the framework's gadget lives at {@code g1}, so members are grafted onto that gadget (a + * UDC binds only one gadget at a time). configfs refuses config-link edits while the UDC is + * bound (EINVAL), and the vendor USB HAL races to grab the UDC back within about a second of + * an unbind -- so unbind, edit, bind runs as one uninterrupted sequence. The framework owns + * g1: a USB mode switch or cable event may silently drop the members; the next acquire + * notices and rebuilds.
+ */ +public final class UsbAcmPool { + private static final String TAG = "UsbAcmPool"; + private static final String GADGET = "/config/usb_gadget/g1"; + private static final String FUNCTIONS_DIR = pathJoin(GADGET, "functions"); + private static final String CONFIG_DIR = pathJoin(GADGET, "configs", "b.1"); + private static final String UDC_FILE = pathJoin(GADGET, "UDC"); + private static final String UDC_CLASS_DIR = "/sys/class/udc"; + private static final String INSTANCE_PREFIX = "dvmpool"; + private static final int NODE_WAIT_MS = 3000; + private static final int NODE_POLL_MS = 50; + // The vendor USB HAL owns this gadget and re-grabs the UDC around unbinds -- and for a + // while after boot it churns the whole stack, which is exactly when the daemon first + // reconciles. Rather than fighting it with blind retries (every lost round re-enumerates + // USB for the host), each attempt first waits for the stack to look settled: boot + // completed and the UDC holding one steady non-empty value across a probe interval. + // sys.usb.state is empty on this vendor, so the UDC file itself is the settle signal. + private static final int UDC_RETRIES = 3; + private static final int SETTLE_PROBE_MS = 300; + private static final int SETTLE_POLL_MS = 500; + private static final int SETTLE_TIMEOUT_MS = 15000; + /** Same keys the settings screen writes; they flow to the daemon via set_app_config. */ + public static final String KEY_USB_ACM_ENABLE = "usb_acm_enable"; + public static final String KEY_USB_ACM_PORTS = "usb_acm_ports"; + /** Off by default: the pool is a standing gadget change every daemon start would replay + * (USB re-enumeration plus host COM ports nobody may be using). */ + public static final boolean DEFAULT_ENABLE = false; + public static final int DEFAULT_PORTS = 4; + // u_serial tops out around 8 ports, and the gadget's endpoint budget (after mtp+adb) + // fits 4-6 ACMs comfortably. + public static final int MAX_PORTS = 6; + + /** Pool member instance name -> owner token; entries only exist while attached. */ + private static final MapHeld under {@link #startLock} from the state test to the worker being handed the VM, + * because two callers reaching here at once is no longer hypothetical: the auto-start sweep + * runs behind the daemon's socket now, so a client's {@code vm_start} can arrive while the + * sweep is looking at the same VM. Unlocked, both read STOPPED, both pass the test, and the + * VM gets two worker threads and two crosvm processes against one set of taps and sockets. + * The test alone cannot be made atomic -- it is the whole run-up to {@code setState} that has + * to be, since that is what publishes the claim.
+ */ @SuppressWarnings("BooleanMethodIsAlwaysInverted") public boolean start() { - // REBOOTING is accepted too: the reboot relaunch calls start() from that - // transient state (process already gone) and goes straight to STARTING. - if (state != VMState.STOPPED && state != VMState.REBOOTING) { - Log.w(TAG, fmt("VM %s is not stopped (state=%s), cannot start", getId(), state.name())); - return false; + synchronized (startLock) { + // REBOOTING is accepted too: the reboot relaunch calls start() from that + // transient state (process already gone) and goes straight to STARTING. + if (state != VMState.STOPPED && state != VMState.REBOOTING) { + Log.w(TAG, fmt("VM %s is not stopped (state=%s), cannot start", + getId(), state.name())); + return false; + } + joinThreads(1000); + if (!setupTaps()) return false; + resolveVncConfig(); + stoppedByUser = false; + exitCode = -1; + setState(VMState.STARTING); + var vmIdStr = getId().toString(); + workerThread = new Thread(this::runVM, fmt("VM-%s", vmIdStr)); + workerThread.setDaemon(true); + workerThread.start(); + Log.i(TAG, fmt( + "Start requested for VM: %s [%s] via %s", + getName(), vmIdStr, getBackend().name() + )); + return true; } - joinThreads(1000); - if (!setupTaps()) return false; - if (item.optBoolean("vnc_enabled", false)) resolveVncConfig(); - stoppedByUser = false; - exitCode = -1; - setState(VMState.STARTING); - var vmIdStr = getId().toString(); - workerThread = new Thread(this::runVM, fmt("VM-%s", vmIdStr)); - workerThread.setDaemon(true); - workerThread.start(); - Log.i(TAG, fmt( - "Start requested for VM: %s [%s] via %s", - getName(), vmIdStr, getBackend().name() - )); - return true; } private void setupTap(int index, ListPer screen, not per VM: two screens exporting over VNC are two servers, and crosvm + * refuses to start when they land on the same port -- so an unset port is resolved once for + * each of them, and the one just handed out is held against the next lookup because it is not + * bound yet and would otherwise still look free.
+ */ private void resolveVncConfig() { - if (item.optLong("vnc_port", -1) <= 0) { - int port = generateRandomAvailablePort(); - if (port > 0) { - item.set("vnc_port", port); - Log.i(TAG, fmt("VM %s: auto-assigned VNC port %d", getName(), port)); - } else { - Log.e(TAG, fmt("VM %s: failed to find available VNC port", getName())); + var taken = new ArrayListThe sweep waits -- up to ten seconds a VM for the huge-page reserve -- and it used to do + * that inside the {@code ServerContext} constructor, which is before the daemon has bound its + * socket, installed its signal handlers or wired up VM events. Every one of those was held + * behind VMs that had not started yet: no RPC, no clean answer to SIGTERM, and the state + * changes of the VMs it did start fired into a callback nobody had set. Behind the socket + * instead, so the daemon answers while its VMs come up. + * + *
Started from {@link cn.classfun.droidvm.daemon.server.Server#run} rather than from the + * context, which also means a daemon whose socket would not bind no longer starts VMs on its + * way to giving up.
+ */ + public void autoUpAsync() { + var sweep = new Thread(this::autoUp, "VMAutoUp"); + sweep.setDaemon(true); + autoUpThread = sweep; + sweep.start(); + } + + /** Waits for a running {@link #autoUpAsync} sweep to notice {@link #shuttingDown} and finish. */ + private void joinAutoUp() { + var sweep = autoUpThread; + if (sweep == null || !sweep.isAlive()) return; + Log.i(TAG, "waiting for the auto-start sweep to stand down"); + try { + // Generous: the flag is read once a second in the wait and again before each start, and + // start() itself only sets up taps and hands off to a thread. A sweep still inside + // start() when this runs out is caught by the collection below, which sees STARTING. + sweep.join(AUTO_UP_JOIN_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + /** + * Starts every VM marked auto_up, one at a time, waiting for the reserve before each. + * + *The list is taken first and started afterwards, rather than started from inside the + * iteration. The wait is seconds long and the daemon is answering RPC by the time this runs, so + * iterating across it would hold a plain {@code ArrayList} open for that whole time against + * clients creating and deleting VMs -- and one {@code vm_delete} landing in the middle of it is + * a {@code ConcurrentModificationException} that ends the sweep and leaves the rest of the VMs + * down. A snapshot narrows that to the moment it takes to collect.
+ * + *Nobody is watching a background start, so instead of asking (which is what the GUI does) + * it waits for the huge-page reserve to cover each VM: a pool that is short only because the + * previous VM has just exited recovers in about two seconds, and starting into the gap is what + * makes the hypervisor migrate memory out of CMA -- observed to stall the whole host for + * minutes, or reset the phone. That is the ordinary case here rather than a rare one, because + * the context reaps the previous daemon's VMs immediately before this runs. Half way through + * the wait the module is asked to go and fetch more. If it never gets there the VM starts + * regardless: an auto-start that silently does not happen is worse than a slow one, and the VMM + * checks again at the point where it actually hands the memory over.
+ */ public void autoUp() { + var pending = new ArrayListWhy they are needed at all: measured on device, a process that drops to the app's uid but + * carries no supplementary groups cannot even {@code stat()} a directory under + * {@code /storage/emulated/0} -- traversing the MediaProvider FUSE mount needs AID_EVERYBODY + * (9997), which every app process carries and no permission grants. The permission-derived + * groups (ext_data_rw and friends) turned out not to be the ones that gate it. Rather + * than hardcode a number whose meaning was inferred from one device, take the app's own list: + * whatever the platform decided the app is, the file server should look the same. + * + *
Which is why this reads the running app process rather than asking PackageManager.
+ * {@code getPackageGids()} knows only the permission-derived half; the framework-assigned half
+ * (the everybody gid, the cache gid, the shared gid) is added by Zygote at spawn time and exists
+ * assembled only in {@code /proc/ And why it is cached to disk: a VM can be started over the daemon's IPC long after the UI
+ * process has gone away, and there would then be nothing to read. A stale list is not a hazard
+ * here -- these groups change only when the user changes a permission, and the failure mode of a
+ * stale one is a shared directory that cannot see its files, not one that sees too much.
+ */
+public final class AppGroups {
+ private static final String TAG = "AppGroups";
+ private static final String CACHE_PATH = pathJoin(DATA_DIR, "run", "app-gids");
+
+ @Nullable
+ private static volatile int[] cached;
+
+ private AppGroups() {
+ }
+
+ /**
+ * The app's supplementary groups, or {@code null} if they cannot be determined.
+ *
+ * A {@code null} is not a reason to fall back to root: a caller that asked for the app's
+ * identity and cannot be given it should say so and stop, or the switch that requested the
+ * drop would silently mean its opposite.
+ */
+ @Nullable
+ public static int[] resolve(int appUid) {
+ var hit = cached;
+ if (hit != null) return hit;
+ synchronized (AppGroups.class) {
+ if (cached != null) return cached;
+ var live = readFromAppProcess(appUid);
+ if (live != null) {
+ cached = live;
+ persist(live);
+ Log.i(TAG, fmt("app groups from the running app process: %s", join(live)));
+ return live;
+ }
+ var stored = readCache();
+ if (stored != null) {
+ cached = stored;
+ Log.i(TAG, fmt("app groups from cache (app not running): %s", join(stored)));
+ return stored;
+ }
+ }
+ Log.w(TAG, "app groups unknown: the app is not running and nothing was cached");
+ return null;
+ }
+
+ /** Formats the list the way crosvm's `supp_gids=` key expects. */
+ public static String join(int[] gids) {
+ var sb = new StringBuilder();
+ for (int i = 0; i < gids.length; i++) {
+ if (i > 0) sb.append(',');
+ sb.append(gids[i]);
+ }
+ return sb.toString();
+ }
+
+ @Nullable
+ private static int[] readFromAppProcess(int appUid) {
+ var proc = new File("/proc").listFiles();
+ if (proc == null) return null;
+ for (var entry : proc) {
+ var name = entry.getName();
+ if (name.isEmpty() || !Character.isDigit(name.charAt(0))) continue;
+ try {
+ // Both checks matter: the daemon itself runs the app's code out of the app's
+ // CLASSPATH, so a name match alone would happily find a process running as root.
+ if (android.system.Os.stat(entry.getPath()).st_uid != appUid) continue;
+ var cmdline = readFile(new File(entry, "cmdline"));
+ int nul = cmdline.indexOf('\0');
+ if (nul >= 0) cmdline = cmdline.substring(0, nul);
+ if (!BuildConfig.APPLICATION_ID.equals(cmdline)) continue;
+ var gids = parseGroups(readFile(new File(entry, "status")));
+ if (gids != null) return gids;
+ } catch (Throwable ignored) {
+ // A pid that went away between listing and reading is ordinary, not an error.
+ }
+ }
+ return null;
+ }
+
+ /** Pulls the {@code Groups:} line out of {@code /proc/ Soft-fail: any error is logged and {@link #gpuCgroupPath} stays null so
+ * {@link #buildCpuPlacementCommand} simply omits the flag. Better to run
+ * without GPU thread isolation than to refuse to start the VM.
+ */
+ private void prepareGpuCgroup() {
+ gpuCgroupPath = null;
+ var item = config.item;
+ // The switch and the device both: the threads this cpuset exists to hold are the
+ // virtio-gpu device's workers, so with no device there is nobody to put in it.
+ if (!CpuPlacementPlan.wantsGpuCgroup(item)) return;
+ var path = item.optString(CpuPlacementPlan.KEY_GPU_CGROUP_PATH,
+ CpuPlacementPlan.DEFAULT_GPU_CGROUP_PATH).trim();
+ var cpus = item.optString(CpuPlacementPlan.KEY_GPU_CGROUP_CPUS, "").trim();
+ if (path.isEmpty() || !path.startsWith("/")) {
+ Log.w(TAG, fmt("gpu-cgroup-path is not an absolute path: '%s'; skipping", path));
+ return;
+ }
+ if (cpus.isEmpty()) {
+ Log.w(TAG, "gpu_cgroup_cpus is empty; cannot set up cpuset, skipping");
+ return;
+ }
+ // Parent dir for inheriting cpuset.mems (single NUMA node = "0" on all
+ // Android devices, but copy the parent rather than hard-coding it).
+ var parent = new java.io.File(path).getParent();
+ if (parent == null) parent = "/dev/cpuset";
+ var ep = RunUtils.escapedString(path);
+ var ec = RunUtils.escapedString(cpus);
+ var eq = RunUtils.escapedString(parent);
+ // Shell.cmd feeds the whole string to the persistent root shell; newlines work.
+ // The three paths go in as shell variables, so the body below stays a plain
+ // literal instead of interleaving quoting with concatenation.
+ var script = fmt(
+ "p=%s\n" +
+ "c=%s\n" +
+ "q=%s\n" +
+ "mkdir -p \"$p\" || exit 1\n" +
+ // mems first: some kernels validate cpus against a non-empty mems
+ "for n in mems cpuset.mems; do\n" +
+ " if [ -e \"$p/$n\" ] && [ ! -s \"$p/$n\" ]; then\n" +
+ " v=$(cat \"$q/$n\" 2>/dev/null); [ -n \"$v\" ] || v=0\n" +
+ " echo \"$v\" > \"$p/$n\"\n" +
+ " fi\n" +
+ "done\n" +
+ // cpuset v1 (noprefix) uses 'cpus'; v2 uses 'cpuset.cpus' -- try both
+ "for n in cpus cpuset.cpus; do\n" +
+ " if [ -e \"$p/$n\" ]; then echo \"$c\" > \"$p/$n\"; fi\n" +
+ "done\n" +
+ // Last line output verifies the write; also becomes the script exit code
+ "cat \"$p/cpus\" 2>/dev/null || cat \"$p/cpuset.cpus\" 2>/dev/null",
+ ep, ec, eq);
+ var result = RunUtils.run(script);
+ if (!result.isSuccess() || result.getOutString().trim().isEmpty()) {
+ Log.e(TAG, fmt("Failed to set up gpuworker cpuset at %s (cpus=%s): %s",
+ path, cpus, result.getErrString()));
+ return;
+ }
+ Log.i(TAG, fmt("gpuworker cpuset ready: %s (cpus=%s)", path, result.getOutString().trim()));
+ gpuCgroupPath = path;
+ }
+
+ /**
+ * Appends CPU placement flags: per-vCPU host affinity, guest capacity, guest
+ * clusters, and (when the cpuset was successfully prepared) the GPU cgroup.
+ */
+ private void buildCpuPlacementCommand(@NonNull List Configs written before that split carry only {@code gpu_api}, whose meaning depended on
+ * the renderer, so fall back to the same migration the editor shows. Reading {@code gpu_api}
+ * directly is what this replaces: a VM configured through the new rows stores
+ * {@code gpu_mode=native} and no longer sets {@code gpu_api=drm2kgsl}, so the drm2kgsl branch below
+ * would silently not fire and the VM would come up without context-types=drm.
+ */
+ @NonNull
+ private static GpuMode effectiveGpuMode(@NonNull DataItem item) {
+ var mode = optEnum(item, "gpu_mode", GpuMode.NONE);
+ if (mode != GpuMode.NONE) return mode;
+ return GpuMode.fromLegacyApi(optEnum(item, "gpu_api", GpuApi.NONE));
+ }
+
+ /**
+ * The two display devices: {@code --gpu} for the virtio-gpu screen, {@code --simplefb} for the
+ * simplefb screen, each emitted exactly when its own switch is on.
+ *
+ * One predicate per device, which is the whole point of the split. The arbitration-era
+ * version emitted the GPU device's {@code displays=} for either screen, because back then the
+ * simplefb bridge had no display of its own and handed its frames to this device -- so a VM
+ * with only the simplefb screen on still got a virtio-gpu scanout, a Linux guest saw a
+ * virtio-gpu output, drew its desktop onto it, and nobody exported it. That bridge is gone
+ * (crosvm's simplefb screen opens its own sink), so the geometry belongs to the screen it
+ * describes and to nothing else. {@code --gpu} and its {@code displays=} are one thing, never two: a virtio-gpu device
+ * with no scanout was tried and no guest desktop ever came up on it, so it is not a
+ * configuration this emits. Native display means crosvm registers an ICrosvmAndroidDisplayService binder under that
+ * screen's name and renders its output straight into the Android Surface the UI hands it.
+ * Touch/keyboard come back over the VM's input sockets, whose paths must match NativeDisplay.
+ *
+ * Every binding names its screen explicitly. crosvm still accepts an exporter with no
+ * {@code screen=} and resolves it to whichever screen a pre-screens command line would have
+ * landed on, but writing it out means the app and the VMM agree in the config file rather
+ * than in two copies of the same defaulting rule. crosvm rejects an exporter naming a screen
+ * whose device is not configured, which is why every binding here is gated on
+ * {@link #isScreenEnabled} -- the same predicate that decides whether {@code --gpu} and
+ * {@code --simplefb} are emitted at all. A ceiling is emitted only where it asks for less than the pipeline would have
+ * given anyway; see {@link DisplayTransportCap#emittedToken}, which is where the rule lives so
+ * that a test can read the whole table off it. A flag whose presence and absence mean the same
+ * thing is worse than no flag, so the top rung of each ladder is spelt by saying nothing. VNC's ladder gained a middle now that the encoder is above it, and {@code gpu} is that
+ * middle: "blit this screen, but do not stand an encoder behind it". crosvm's
+ * {@code transport-cap} enum grew the same way -- new tokens added beside {@code cpu}, nothing
+ * re-spelt -- so this stays a lookup rather than a translation. Both exporters, because multi-touch is not an RFB protocol event: the app is what turns
+ * finger contacts into evdev slots, on the VNC console exactly as on the native one, and
+ * injects them into this screen's socket while that console is up. So a VNC-exported screen
+ * keeps its touchscreen socket and its {@code --input multi-touch} on the same terms it
+ * always had. One list, read by both the socket pre-bind and the {@code --input} args, because a screen
+ * in one and not the other is either a device crosvm cannot connect to (the VM does not
+ * start) or a socket nothing ever opens. A VNC-exported screen has both of those too, but crosvm builds them behind that screen's
+ * VNC server and writes the RFB pointer and key events straight into them, which is what makes
+ * a coordinate land under the geometry of the binding it arrived on and a keystroke reach the
+ * screen the client is looking at. There is no socket for the daemon to bind and no
+ * {@code --input} for it to emit; the whole of the daemon's say in them is the
+ * {@code view-only} flag on that screen's exporter (see {@link #buildVncArg}). One list for the pair, because the pair has one rule: both are the screen's, both exist
+ * only where the screen's input switch is on, and both are crosvm's on a VNC binding. Read by
+ * the socket pre-bind and the {@code --input} args alike, for the same reason the touchscreen
+ * list is. TURNIP points the crosvm bridge at the bundled turnip. OFF -- and, until they are wired,
+ * PANVK/SYSTEM -- forces crosvm's CPU copy so a stale or hand-edited value degrades cleanly
+ * instead of half-loading a wrong driver. (SYSTEM will instead leave the library unset and let
+ * the bridge load the SoC driver once the capability probe that gates it exists.)
+ */
+ private void applyDisplayBlitEnv(@NonNull NativeProcess.Builder builder) {
var item = config.item;
- if (!item.optBoolean("vnc_enabled", false)) return;
+ // Any binding this VM actually has whose transport could be a GPU one -- not the native
+ // display's in particular. The env var is process-wide, so it is set from whether that
+ // path exists at all, and it exists for the VNC sink just as much since it grew a blit of
+ // its own: same driver, same dma-buf import, a headless target instead of a Surface.
+ // Naming the native display here was the rule from when it was the only sink that blitted.
+ if (!VMScreenConfig.hasGpuBlitBinding(item)) return;
+ var provider = optEnum(item, "display_blit_provider", GpuBlitProvider.TURNIP);
+ switch (provider) {
+ case TURNIP: {
+ var turnip = pathJoin(DATA_DIR, "usr", "lib", "libvulkan_freedreno.so");
+ if (new File(turnip).exists())
+ builder.environment("CROSVM_DISPLAY_VULKAN_LIBRARY", turnip);
+ break;
+ }
+ case SYSTEM: {
+ // The SoC's stock Vulkan performs the blit. The bridge dlopens whatever it is
+ // pointed at as a hwvulkan HMI, and the vendor driver under /vendor/lib64/hw is one,
+ // so aim it there instead of turnip. The bridge's own extension probe drops to the
+ // CPU copy when the stock driver lacks raw-dmabuf import (as Qualcomm's does) or
+ // cannot be loaded -- so SYSTEM attempts the system Vulkan and degrades, it never
+ // forces the CPU path.
+ var sysVk = resolveSystemVulkanHal();
+ if (sysVk != null)
+ builder.environment("CROSVM_DISPLAY_VULKAN_LIBRARY", sysVk);
+ break;
+ }
+ case OFF:
+ case PANVK:
+ default:
+ // OFF is explicit; PANVK is not built yet (and is bounced in the editor). Force the
+ // CPU copy rather than letting the bridge load the wrong driver.
+ builder.environment("GPU_DISPLAY_COPY_MODE", "cpu");
+ break;
+ }
+ }
+
+ /**
+ * The SoC's stock Vulkan hwvulkan HAL under {@code /vendor/lib64/hw} -- a real hwvulkan HMI the
+ * display bridge can dlopen -- or null if only a software rasteriser is present. Used by the
+ * SYSTEM {@link GpuBlitProvider}.
+ */
+ private static String resolveSystemVulkanHal() {
+ var files = new File("/vendor/lib64/hw").listFiles(
+ (d, name) -> name.startsWith("vulkan.") && name.endsWith(".so"));
+ if (files == null) return null;
+ for (var vf : files) {
+ var n = vf.getName();
+ // Skip the software fallbacks (lvp/swiftshader/pastel); we want the GPU driver.
+ if (n.contains("lvp") || n.contains("swiftshader") || n.contains("pastel")) continue;
+ return vf.getAbsolutePath();
+ }
+ return null;
+ }
+
+ /** Whether this VM has [screenId]'s display device -- the screen's own switch, and nothing else. */
+ private boolean isScreenEnabled(@NonNull String screenId) {
+ var screen = VMScreenConfig.find(config.item, screenId);
+ return screen != null && screen.isEnabled();
+ }
+
+ /**
+ * The evdev input bridge (and matching --input devices) is needed by both app display paths:
+ * native uses it for every input; the VNC display uses it for MOUSE/TOUCH modes (tablet
+ * pointer + keyboard ride the RFB channel instead). So: any screen with any exporter on it.
+ *
+ * Single source of truth: this gates both the socket pre-bind in start() and the --input
+ * args in buildCommand(), so the sockets and the devices never diverge. It is the gate on the
+ * VM-wide relative pointer; which screens additionally get a touchscreen is
+ * {@link #touchscreenScreens} and which get a socket tablet and keyboard is
+ * {@link #nativeInputScreens}, and a VM with every screen's input switched off still gets the
+ * relative pointer -- it is not a screen's to switch off. The keyboard used to be in that
+ * sentence and no longer is. A VIRTIO_SOUND peripheral is `--virtio-snd` with a `uid`: the audio has to leave
+ * the root process to be heard at all, because Android silences AAudio playback from uid 0
+ * outright and hands back zeroed buffers for capture. Measured on device with the same probe
+ * under different uids -- root muted both ways, shell, system and the app's own uid all fine.
+ * crosvm does the moving itself, re-execing its own `device snd` backend under that uid and
+ * reaching it over a socketpair. The daemon deliberately does not spawn that process: it did
+ * once, and every part of doing so -- `su`, a rendezvous socket to wait for, a pid to kill on
+ * teardown -- was a way to get it wrong. INTEL_HDA is accepted by the model and skipped here: crosvm emulates no HDA controller,
+ * and starting a VM that claims hardware nothing can serve is worse than starting without
+ * it. The UI says the same thing on the row. The `uid` is what makes this audible at all. Android decides whether a stream can be
+ * heard from the uid that opened it and silences uid 0 in both directions, and crosvm runs as
+ * root -- so crosvm re-execs itself under this uid and serves the device over a socketpair.
+ * Using the app's own uid rather than any other non-root one is what makes Android attribute
+ * the audio, and the microphone indicator, to DroidVM instead of to an anonymous process. Their order here is their `hda_fn_nid` on the other side, which is what ties a stream to
+ * the endpoint it belongs to -- so it has to match the order the counts were taken in. The socket set is not one per channel: the relative pointer is VM-wide, while multi-touch,
+ * the absolute pointer and the keyboard exist once per screen that has them, so a slot is a
+ * (screen, channel) pair and the screens that get one are decided by the config. Everything here
+ * is therefore keyed rather than indexed -- a flat channel-indexed array cannot say which screen a
+ * write is for, and silently picking one would put touches, or typing, on the wrong output. The three per-screen channels do not cover the same screens, which is why they arrive as two
+ * lists rather than one. A VNC-exported screen's absolute pointer and keyboard are not ours:
+ * crosvm builds that screen's pair behind its own VNC server and feeds them from RFB pointer and
+ * key events, so there is no {@code --input} for either and a socket bound here would be an inode
+ * crosvm never connects to. Its touchscreen is still ours on the same terms as a native screen's,
+ * because multi-touch has no RFB event to arrive as. {@link NativeDisplay#isPerScreen} is the only place that split is decided, so a channel
+ * becoming per screen moves the socket name and this key together -- there is no second copy
+ * of the rule here to forget to update. [touchScreens] are the screens that get a multi-touch device and [nativeScreens] the ones
+ * that get an absolute pointer and a keyboard -- the same two lists
+ * {@link CrosvmBackendInstance} emits {@code --input} devices from, so the sockets and the
+ * devices cannot diverge. They are not the same list: the second holds only the natively
+ * exported screens, because a VNC-exported screen's tablet and keyboard are crosvm's own. A
+ * screen left out of a list has no socket and no device on that channel; input aimed at it is
+ * refused rather than landing on some other screen's geometry, or on a screen whose user
+ * switched input off. Throws IllegalArgumentException if a path does not fit a unix socket address. That is the
+ * one failure here that is not survivable and not diagnosable after the fact: bind(2) truncates
+ * silently, so the daemon would report a live listener on an inode crosvm was never told about.
+ * The caller turns it into a refused start; see {@link NativeDisplay#requireBindablePath}. Whatever it bound before that throw is closed on the way out, because the set is not
+ * published until the loop finishes and {@link #release()} can only free what it can see. The
+ * screen that trips the length check is by definition not the first one, so there is always
+ * something bound behind it: listening fds, inodes under run/, and an accept thread each --
+ * parked in accept(2) forever, since only closing the fd it waits on ends one. A bind that fails is left behind rather than unwinding the rest: the VM still starts, with
+ * that one device missing, which is the behaviour the caller's warning describes. A path the
+ * kernel cannot hold is the other kind of failure and throws out of here -- see
+ * {@link #startListening}, which is where what is already in [built] is disposed of. Switched on the channel rather than on {@link NativeDisplay#isPerScreen} alone, because
+ * "is this per screen" and "which screens" stopped having one answer when the VNC bindings
+ * took over their own tablets and keyboards. The tablet and the keyboard share a list: both
+ * are the screen's, both exist only where its input switch is on, and both are crosvm's on a
+ * VNC binding. Latching {@code inputClosed} first is not tidiness but the order the accept loop needs.
+ * Closing a server fd is what unblocks the thread parked in accept(2) on it, and that thread
+ * then reads the flag to decide whether the failure means "we are shutting down" or "retry in
+ * 200 ms". Closing first would leave it retrying accept on a closed fd for the life of the
+ * daemon. Only the on/off decision is plumbed here. The mode, host endpoint, buffer and underrun
+ * settings of a VirtIO Sound peripheral are all crosvm-side concepts -- QEMU's aaudio driver
+ * opens whatever the platform routes to, in one process, as whatever uid QEMU runs as -- so
+ * they are ignored rather than half-honoured. Intel HDA is ignored here too, even though
+ * this QEMU does emulate one: wiring it would be a separate piece of work with its own
+ * verification. A config that predates the peripheral tab still works through the old
+ * {@code audio_enabled} key, which also stays available as an override. The virtio-gpu screen's switch is the device, exactly as on crosvm; the renderer only
+ * decides which QEMU device model implements it. The geometry is that screen's own, so a VM
+ * with both screens no longer has to give them one size. QEMU has no {@code screen=} to bind an exporter to -- one {@code -vnc} serves whatever
+ * the machine displays -- so the per-screen model only reaches this far: the screen switches
+ * decide which devices exist, and the first screen exporting over VNC supplies the server's
+ * settings. Native display is refused for this backend in the editor, so it never appears
+ * here at all. Reads the source images (headers only) and never writes to them; the exporting phone's
+ * disks and their registered parent links come out of an export exactly as they went in.
+ *
+ * @param wanted VM disk indices to include; empty means every disk.
+ */
+ public static void collectDisks(
+ @NonNull PackageManifest manifest,
+ @NonNull VMConfig vm,
+ @NonNull Set {@link AudioDeviceInfo#getId()} is what AAudio wants ({@code AAudioStreamBuilder_setDeviceId},
+ * which is how crosvm's virtio-snd pins a PCM device to one endpoint), but the ids are handed out
+ * per boot and change when something is plugged or paired. So a VM config stores the stable
+ * {@link #keyOf key} instead -- device type plus routing address -- and the backend
+ * {@link #resolve resolves} it to a live id when the VM starts. Usable from the daemon as well as the UI: the lookup only needs a Context that can reach
+ * AudioManager, which the daemon's system context can. An unset field said the same thing until now, and saying it by omission turned out to
+ * be worse in every direction: the config held a device with nothing in it, the command line
+ * carried an entry with no fields, and the option parser has no use for either. It also
+ * cannot be told apart from a field nobody filled in. It is shaped like any other key so nothing has to special-case it -- and it appears in
+ * the published device table against id 0, which is `AAUDIO_DEVICE_UNSPECIFIED`, so
+ * resolving it produces the platform's own routing by the ordinary path rather than by an
+ * exception to it. `DEFAULT` is not an AudioDeviceInfo type name, so a real endpoint can
+ * never collide with it. Separate from {@code list} because that one builds a label for the picker, and a label
+ * needs the app's string resources. The daemon's context has none -- asking it for one throws
+ * {@code Resources$NotFoundException} -- and it has no use for a label anyway: it is
+ * publishing the endpoints for crosvm to match against, not showing them to anyone. These lists say what an endpoint will accept, not what it runs at, and the two are only
+ * the same thing when there is a single entry. Picking a favourite out of several would be a
+ * guess, and a guessed hint is worse than none: the guest treats a hint as the format to
+ * default to, and would then default to something the platform converts. An empty list means
+ * the platform declined to say, which is the same answer. This is what decides the name and icon Windows shows beside the endpoint, so an
+ * approximate answer is still much better than none. An address already contains most of the punctuation worth choosing: a Bluetooth address
+ * is a MAC with colons, a USB one looks like {@code card=1;device=0}. So the separator has to
+ * be something none of them use. By number because there is no public constant for it -- the SDK's list goes 25, 26, 27,
+ * 29 -- and a name that does not exist cannot be compiled against. Unlike {@link HostAudioDevices}, the key stored in the VM config is the platform's own camera
+ * id, because that one is already stable: AudioDeviceInfo ids are handed out per boot, camera ids
+ * are a property of the device. The label is kept alongside it only so a row can still name a
+ * camera the current phone does not have -- a config copied between phones, or an external USB
+ * camera that is unplugged. Enumeration needs no CAMERA permission (the platform lets any uid read characteristics;
+ * measured on device), so the picker can be populated before the grant is asked for. Opening one
+ * does need it, and needs the uid to be foreground besides -- see {@code CameraPermission} and
+ * {@code PeripheralType.needsForegroundService}. The GKI series is the unit several things here are decided by, because it is the unit the
+ * vendor branches are cut on: a Gunyah resource manager, a CMA redirect, a driver's page-list
+ * allocation all behave one way on 6.1 and another on 6.6. The patch level below it never matters
+ * to any of them, so it is dropped rather than compared. Matched as a whole token and not by prefix. A {@code startsWith("6.1")} says yes to a 6.12
+ * kernel, which is a different series with the opposite behaviour in at least one of the places
+ * this is asked -- the same trap {@code KernelModuleManager} documents for its KMI directories. Runs {@code uname}, so not on the main thread. Cached for the life of the process; the kernel
+ * does not change under a running app. Null is "we do not know", and every caller has to treat it as such rather than as "not
+ * that version": the rules built on this are about a kernel that cannot do something, and
+ * guessing wrong in that direction turns a warning into a VM that does not start. Used to decide which host kernel modules even apply here: a Gunyah module is meaningless on a
+ * MediaTek phone, and a future MediaTek module would be meaningless on a Snapdragon one. The token
+ * is the vocabulary the module match rules are written against, so it must stay stable ({@code
+ * qualcomm}, {@code mediatek}, {@code google}, {@code samsung}, {@code unisoc}, {@code hisilicon},
+ * or {@code unknown}) even as the detection below grows more fallbacks.
+ *
+ * Detection starts with the framework's own answer, which needs no shell and no root, and only
+ * then falls back to properties and {@code /proc/cpuinfo} -- vendors do leave {@code
+ * ro.soc.manufacturer} unset. Results are cached: an SoC does not change under a running process.
+ */
+public final class SocIdentity {
+ private static final String TAG = "SocIdentity";
+
+ public static final String QUALCOMM = "qualcomm";
+ public static final String MEDIATEK = "mediatek";
+ public static final String GOOGLE = "google";
+ public static final String SAMSUNG = "samsung";
+ public static final String UNISOC = "unisoc";
+ public static final String HISILICON = "hisilicon";
+ public static final String UNKNOWN = "unknown";
+
+ private static String vendor;
+ private static String model;
+
+ private SocIdentity() {
+ }
+
+ /** Vendor token for this device, never null. May run a shell: call off the main thread. */
+ @NonNull
+ public static synchronized String vendor() {
+ if (vendor == null) {
+ vendor = detectVendor();
+ Log.i(TAG, fmt("SoC vendor: %s (model %s)", vendor, model()));
+ }
+ return vendor;
+ }
+
+ /** Raw SoC model (e.g. "SM8650", "MT6989", "gs201"), or "" when nothing reports one. */
+ @NonNull
+ public static synchronized String model() {
+ if (model == null) {
+ var m = QcomChipName.getCurrentSoC(); // falls back to Build.SOC_MODEL
+ model = m == null ? "" : m.trim();
+ }
+ return model;
+ }
+
+ @NonNull
+ private static String detectVendor() {
+ var fromBuild = fromName(Build.SOC_MANUFACTURER);
+ if (!UNKNOWN.equals(fromBuild)) return fromBuild;
+
+ var fromProp = fromName(prop("ro.soc.manufacturer"));
+ if (!UNKNOWN.equals(fromProp)) return fromProp;
+
+ // QTI-only property: its mere presence identifies the vendor.
+ if (!prop("ro.vendor.qti.soc_model").isEmpty()) return QUALCOMM;
+
+ var hw = prop("ro.hardware").toLowerCase(Locale.ROOT);
+ if (hw.equals("qcom") || hw.startsWith("qcom")) return QUALCOMM;
+ if (hw.startsWith("mt")) return MEDIATEK;
+
+ var fromCpuinfo = fromName(hardwareLine());
+ if (!UNKNOWN.equals(fromCpuinfo)) return fromCpuinfo;
+
+ // Last resort: the model string's own family prefix.
+ var m = model().toUpperCase(Locale.ROOT);
+ if (m.matches("^(SM|SDM|QCS|QCM|MSM|APQ)\\d.*")) return QUALCOMM;
+ if (m.startsWith("MT")) return MEDIATEK;
+ if (m.startsWith("GS") || m.startsWith("ZUMA")) return GOOGLE;
+ if (m.startsWith("EXYNOS") || m.startsWith("S5E")) return SAMSUNG;
+ return UNKNOWN;
+ }
+
+ /** Map whatever a vendor calls itself onto our token. */
+ @NonNull
+ private static String fromName(String raw) {
+ if (raw == null) return UNKNOWN;
+ var s = raw.trim().toLowerCase(Locale.ROOT);
+ if (s.isEmpty() || s.equals("unknown")) return UNKNOWN;
+ if (s.contains("qualcomm") || s.equals("qti") || s.contains("qti ")) return QUALCOMM;
+ if (s.contains("mediatek") || s.contains("mtk")) return MEDIATEK;
+ if (s.contains("google")) return GOOGLE;
+ if (s.contains("samsung") || s.contains("exynos")) return SAMSUNG;
+ if (s.contains("unisoc") || s.contains("spreadtrum")) return UNISOC;
+ if (s.contains("hisilicon") || s.contains("kirin") || s.contains("huawei"))
+ return HISILICON;
+ return UNKNOWN;
+ }
+
+ @NonNull
+ private static String prop(@NonNull String key) {
+ try {
+ return runListQuiet("getprop", key).getOutString().trim();
+ } catch (Exception e) {
+ return "";
+ }
+ }
+
+ /** The {@code Hardware :} line of /proc/cpuinfo, which often names the vendor outright. */
+ @NonNull
+ private static String hardwareLine() {
+ try {
+ var r = runListQuiet("grep", "-m1", "^Hardware", "/proc/cpuinfo");
+ return r.getOutString().trim();
+ } catch (Exception e) {
+ return "";
+ }
+ }
+}
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/diag/LogHelper.java b/app/src/main/java/cn/classfun/droidvm/lib/diag/LogHelper.java
index 35c61338..aa919645 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/diag/LogHelper.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/diag/LogHelper.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.diag;
import android.os.Handler;
@@ -62,6 +65,10 @@ public void onDaemonEvent(@NonNull JSONObject msg) {
var event = data.optString("event");
if (event.equals("exited")) {
vmLogContexts.remove(vmId);
+ // Dropping the context re-arms every once-handler for this vmId's next boot, so
+ // whatever they accumulated under it has to go at the same moment or the next boot
+ // inherits it -- the handlers are singletons, only their state is per VM.
+ for (var handler : handlers) handler.onLogContextReset(vmId);
return;
}
var logs = vmLogContexts.computeIfAbsent(vmId, k -> new LogContext(vmId));
@@ -73,6 +80,9 @@ public void onDaemonEvent(@NonNull JSONObject msg) {
buff.adds(text.getBytes(StandardCharsets.UTF_8));
var full = new String(buff.peekAll(), StandardCharsets.UTF_8);
for (var handler : handlers) {
+ // Before the disabled check, so a handler that reports what the log said keeps
+ // reading it after it has fired; match() below may rely on having been fed first.
+ handler.observe(vmId, stream, full);
if (logs.disabled.contains(handler)) continue;
if (!handler.match(vmId, stream, full)) continue;
Runnable show = () -> vmEventHandler.queueActivityTask(act -> handler.show(act, vmId, vmName));
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/diag/LogHelperHandler.java b/app/src/main/java/cn/classfun/droidvm/lib/diag/LogHelperHandler.java
index e551bbcc..ab5cb1ab 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/diag/LogHelperHandler.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/diag/LogHelperHandler.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.diag;
import static android.content.Intent.ACTION_VIEW;
@@ -6,8 +9,11 @@
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.net.Uri;
+import android.text.util.Linkify;
+import android.widget.TextView;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
@@ -24,6 +30,28 @@ public boolean match(@NonNull UUID vmId, @NonNull String stream, @NonNull String
return false;
}
+ /**
+ * Every line, before {@link #match} and whether or not this handler has already fired.
+ *
+ * For a handler that has to report what the log said rather than only that it said it:
+ * {@link #isOnce} takes the handler out of the match loop the moment it first matches, so
+ * match() cannot be where anything is gathered -- the lines that arrive during the show delay,
+ * and every line after it, would never be looked at. Handler instances are process-wide singletons shared by every VM, so anything a handler
+ * accumulates is keyed by vmId and has to be forgotten here: the context takes the
+ * already-fired mark with it, so the next boot of the same VM re-arms this handler and must
+ * not inherit the last boot's findings. MaterialAlertDialog's three slots are all spoken for here: positive is OK, neutral is the
+ * wiki URL as everywhere else, so [actionId] takes negative. Pass 0 for no extra action. URLs left in the text are made tappable here because nothing else does it: the body is
+ * {@code @android:id/message} styled {@code materialAlertDialogBodyTextStyle}, and material
+ * 1.14.0 sets {@code autoLink} nowhere in the whole AAR, while AppCompat 1.7.0's
+ * AlertController only calls {@code setText} on it -- neither {@code setMovementMethod} nor
+ * {@code Linkify} appears in its bytecode. {@code Linkify.addLinks(TextView, int)} installs
+ * the movement method itself once it has a span to install it for. Two different faults print this same line, which is why the dialog names both: the guest
+ * kernel may have no restricted DMA pool to put the device's buffers in, or the guest driver for
+ * that particular device may not be one of the ported ones and so never allocated from the pool it
+ * does have. The device names are the only thing in the log that separates "the whole VM has no
+ * pool" from "this one driver is wrong", so they are listed rather than summarised away. Every region a Gunyah VM gets at boot -- guest RAM, the GPU pools, swiotlb -- is served
+ * from {@code gh_hugepage_reserve} as isolated 2 MB folios, which the hypervisor can take
+ * without moving anything. When the pool is short the shortfall comes from ordinary movable
+ * memory instead, and that memory cannot be handed over without migrating it out of CMA first.
+ * On a phone with nothing spare that migration is where things end badly: measured outcomes were
+ * a multi-minute whole-host stall that ended with the kernel OOM-killing crosvm, and a
+ * {@code qcom_scm: Assign memory protection call failed -22} that reset the device.
+ *
+ * The pool refills within a couple of seconds of a VM exiting, so the common way to hit this
+ * is simply starting the next VM too soon. That makes the fix cheap: look before starting, and
+ * either wait (background starts) or say so (foreground starts).
+ *
+ * Asked only of the VMs it is about. A VM on any other hypervisor is not served from the reserve
+ * and cannot be delayed by it, so {@link #appliesTo} answers no before anything is read and every
+ * check on such a VM is free and silent.
+ *
+ * Everything here is context-free and does shell I/O -- call it off the UI thread.
+ */
+public final class PoolPreflight {
+ private static final String TAG = "PoolPreflight";
+ private static final String SYSFS_PARAMS = "/sys/module/gh_hugepage_reserve/parameters";
+
+ /** The reserve deals in 2 MB pages; every count here is in those. */
+ public static final long PAGE_MB = 2;
+
+ /**
+ * The waiting policy for a start nobody is watching -- auto-start at daemon boot, and the
+ * relaunch that follows a guest reboot. Ten looks a second apart, asking the module to fetch
+ * more half way through, and start anyway at the end. See {@link #waitForPool}.
+ */
+ public static final int BACKGROUND_ATTEMPTS = 10;
+ public static final long BACKGROUND_INTERVAL_MS = 1000;
+ public static final int BACKGROUND_ACQUIRE_AT = 5;
+
+ /**
+ * The same policy, with more room, for the relaunch after a guest reboot. That start races
+ * the reserve taking back the memory the same VM has only just released, and measured on
+ * device that takes about ten seconds (drm2kgsl: enough again at ~9 s, full at ~16 s; venus:
+ * enough at ~9 s, full at ~13 s) -- too close to the ten of a plain background start to leave
+ * it there. Twice the measured worst case, and still bounded.
+ */
+ public static final int RELAUNCH_ATTEMPTS = 20;
+
+ private PoolPreflight() {
+ }
+
+ /** What the pool can serve versus what this VM will ask of it. */
+ public static final class Status {
+ /**
+ * This VM draws on the reserve and the module is loaded, so the numbers below mean
+ * something. False is the ordinary answer: see {@link #appliesTo}.
+ */
+ public final boolean applicable;
+ /** {@code pool_avail}: 2 MB pages sitting in the reserve, free. */
+ public final long availPages;
+ /** Estimated 2 MB pages this VM's boot-time regions will take. */
+ public final long neededPages;
+
+ Status(boolean applicable, long availPages, long neededPages) {
+ this.applicable = applicable;
+ this.availPages = availPages;
+ this.neededPages = neededPages;
+ }
+
+ public boolean isEnough() {
+ return !applicable || availPages >= neededPages;
+ }
+
+ public long availMb() {
+ return availPages * PAGE_MB;
+ }
+
+ public long neededMb() {
+ return neededPages * PAGE_MB;
+ }
+
+ public long shortMb() {
+ return Math.max(0, neededPages - availPages) * PAGE_MB;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return fmt("pool_avail=%d need=%d (%d MB / %d MB)",
+ availPages, neededPages, availMb(), neededMb());
+ }
+ }
+
+ /**
+ * Whether the reserve has anything to do with this VM.
+ *
+ * Only a Gunyah VM is served from it. That is what the reserve is: isolated folios for the
+ * one hypervisor that takes guest memory away from the host, and the danger it exists to avoid
+ * -- migrating pages out of CMA to hand them over -- is that hypervisor's transfer and nobody
+ * else's. KVM and GenieZone hand over nothing, and a TCG guest is ordinary process memory.
+ * Their VMs pay the reserve no attention, so the reserve must pay them none: a prompt or a wait
+ * for a pool they will not draw on is a delay with no failure behind it. The module being loaded is the second half of the question, not the first. It ships for
+ * Qualcomm SoCs alone -- {@code match.json} gates it on {@code soc_vendor}, and the kernel-module
+ * page hides the card everywhere else -- so on most phones the answer is no twice over. Read
+ * here rather than assumed, because a QEMU-on-Gunyah VM on a Qualcomm phone is both. The memory size plus the guest pool, and nothing else. Everything else the backend passes
+ * is already inside {@code --mem}: crosvm carves the swiotlb and the framebuffer out of it, and
+ * as of the per-pool {@code consume_system_mem} tag so are the three renderer host pools --
+ * whichever of them a route uses, the VM still costs what its memory field says. Only the guest
+ * pool is added on top, because it is video memory the user asked for beside the RAM rather
+ * than out of it - and only when the backend will actually pass one, which
+ * {@link GuestPoolSizing} decides for both sides.
+ *
+ * Growth grants (the runtime SHARE path) are deliberately not counted -- they happen later,
+ * one blob at a time, and a VM that cannot grow still boots. That is also why the guest pool
+ * contributes its pre-allocation and not its window.
+ */
+ public static long neededPages(@NonNull DataItem item) {
+ long mb = Math.max(item.optLong("memory_mb", 512), 64);
+ // Exactly what the backend will pre-allocate: nothing for a host-visible-RAM VM, and
+ // for gfxstream only with udmabuf. One rule, shared with the command builder.
+ mb += GuestPoolSizing.bootGuestPreallocMb(item);
+ return (mb + PAGE_MB - 1) / PAGE_MB;
+ }
+
+ /**
+ * Waits for the reserve to cover this VM, for background starts (auto-start, and the daemon
+ * re-launching VMs after a reboot) where there is nobody to ask.
+ *
+ * One second between looks, because a normal refill lands in about two. Half way through
+ * it asks the module to go and get more; that is worth one shot and no more, since a reserve
+ * that cannot be filled will not be filled by asking twice. If the wait runs out we start
+ * anyway: refusing to boot a VM the user asked to auto-start is worse than a boot that may
+ * be slow, and the VMM has its own guard at the point where it actually hands memory over.
+ *
+ * @return true if the pool covered the VM before the attempts ran out
+ */
+ /** {@link #waitForPool} with the shared background policy. */
+ public static boolean waitForPool(@NonNull DataItem item) {
+ return waitForPool(item, BACKGROUND_ATTEMPTS, BACKGROUND_INTERVAL_MS, BACKGROUND_ACQUIRE_AT);
+ }
+
+ /** {@link #waitForPool} for a caller with nothing that would call the wait off. */
+ public static boolean waitForPool(@NonNull DataItem item, int attempts, long sleepMs,
+ int acquireAt) {
+ return waitForPool(item, attempts, sleepMs, acquireAt, () -> false);
+ }
+
+ /**
+ * The same wait, with [abort] read once a second so a caller can call it off.
+ *
+ * Ten seconds is a long time to be inside when the daemon is going down, and the thing the
+ * wait is for -- a VM that has not started yet -- is exactly what a shutdown no longer wants
+ * started. Read between looks rather than by interrupting the thread, because an interrupt
+ * would also land on whatever the caller does after this returns. The crosvm display bridge enables a fixed set of device extensions to import the virtio-gpu
+ * scanout dmabuf and blit it into the SurfaceControl buffer; a driver missing them cannot run the
+ * blit and the bridge falls back to a CPU copy. {@link cn.classfun.droidvm.lib.store.vm.GpuBlitProvider#SYSTEM}
+ * points that bridge at the SoC's own driver, so this lets the editor tell the user up front which
+ * extensions (if any) their platform lacks. It is a general capability check -- it inspects the
+ * real driver's extension list, with no per-vendor assumptions.
+ *
+ * The result is a property of the phone, not of any VM, so it is probed once and cached.
+ */
+public final class VulkanBlitProbe {
+ private static final boolean LOADED;
+
+ static {
+ boolean ok;
+ try {
+ System.loadLibrary("vkprobe");
+ ok = true;
+ } catch (Throwable t) {
+ ok = false;
+ }
+ LOADED = ok;
+ }
+
+ private static boolean probed;
+ @Nullable private static String[] cached;
+
+ private VulkanBlitProbe() {}
+
+ /**
+ * Required blit extensions the system Vulkan driver is missing.
+ *
+ * @return an empty array if a physical device supports all of them (SYSTEM blit is usable);
+ * a non-empty array naming the missing extensions; or {@code null} if the probe could not
+ * run at all (no loader / no device), i.e. capability is unknown.
+ */
+ @Nullable
+ public static synchronized String[] missingBlitExtensions() {
+ if (!probed) {
+ String[] r = null;
+ if (LOADED) {
+ try {
+ r = nativeMissingBlitExtensions();
+ } catch (Throwable t) {
+ r = null;
+ }
+ }
+ cached = r;
+ probed = true;
+ }
+ return cached == null ? null : cached.clone();
+ }
+
+ private static native String[] nativeMissingBlitExtensions();
+}
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/network/FDSocket.java b/app/src/main/java/cn/classfun/droidvm/lib/network/FDSocket.java
index 731ab374..894617d4 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/network/FDSocket.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/network/FDSocket.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.network;
import android.os.ParcelFileDescriptor;
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/network/IPAddress.java b/app/src/main/java/cn/classfun/droidvm/lib/network/IPAddress.java
index 283c03be..090e9a99 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/network/IPAddress.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/network/IPAddress.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.network;
import static cn.classfun.droidvm.lib.utils.StringUtils.fmt;
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/network/IPNetwork.java b/app/src/main/java/cn/classfun/droidvm/lib/network/IPNetwork.java
index ad3ac87d..6df9a358 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/network/IPNetwork.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/network/IPNetwork.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.network;
import static cn.classfun.droidvm.lib.utils.StringUtils.fmt;
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/network/IPv4Address.java b/app/src/main/java/cn/classfun/droidvm/lib/network/IPv4Address.java
index 90c58aa3..0e05fd4b 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/network/IPv4Address.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/network/IPv4Address.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.network;
import static cn.classfun.droidvm.lib.utils.StringUtils.fmt;
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/network/IPv4Network.java b/app/src/main/java/cn/classfun/droidvm/lib/network/IPv4Network.java
index e293d266..8f81d1ac 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/network/IPv4Network.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/network/IPv4Network.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.network;
import static cn.classfun.droidvm.lib.utils.StringUtils.fmt;
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/network/IPv6Address.java b/app/src/main/java/cn/classfun/droidvm/lib/network/IPv6Address.java
index 21b6c52c..3fe34f9e 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/network/IPv6Address.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/network/IPv6Address.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.network;
import static java.lang.System.arraycopy;
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/network/IPv6Network.java b/app/src/main/java/cn/classfun/droidvm/lib/network/IPv6Network.java
index 34b0918f..6d154c00 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/network/IPv6Network.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/network/IPv6Network.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.network;
import static cn.classfun.droidvm.lib.network.IPv6Address.MAX_VALUE;
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/perf/GamePerfHint.java b/app/src/main/java/cn/classfun/droidvm/lib/perf/GamePerfHint.java
new file mode 100644
index 00000000..0ba6e6d9
--- /dev/null
+++ b/app/src/main/java/cn/classfun/droidvm/lib/perf/GamePerfHint.java
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
+package cn.classfun.droidvm.lib.perf;
+
+import static cn.classfun.droidvm.lib.utils.StringUtils.fmt;
+
+import android.app.GameManager;
+import android.app.GameState;
+import android.content.Context;
+import android.os.Build;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Declares a heavy, uninterruptible 3D workload to the platform while a VM display is in the
+ * foreground, so the device's own power policy raises CPU/GPU clocks -- the sanctioned way.
+ *
+ * Why this exists: the Adreno {@code msm-adreno-tz} governor parks the GPU at its minimum clock
+ * under the bursty, latency-coupled gfxstream render pattern. Measured on an 8 Elite: a guest 3D
+ * workload registers only ~55% GPU busy at 160MHz (of 1100MHz), so the throughput-oriented
+ * governor never ramps up -- the GPU runs ~7x slower than it could, and a guest benchmark scores
+ * ~1800 instead of ~3900. Writing {@code /sys/class/kgsl/kgsl-3d0/devfreq/min_freq} fixes it, but
+ * that needs root and leaves a device-wide clock override that must be restored by hand. The
+ * platform path is a *declaration* instead: {@code android:appCategory="game"} in the manifest
+ * plus the {@link GameState} below, which feeds the OEM's game power profile.
+ *
+ * Note on ADPF: the finer-grained {@link android.os.PerformanceHintManager} is deliberately not
+ * used here. It only accepts thread ids owned by the caller's uid, but crosvm is spawned by the
+ * root daemon (uid 0) while this code runs in the normal app process, so its threads cannot be
+ * registered. {@code GameState} is a device-level declaration, so the root-owned crosvm process
+ * still benefits from it. (A future option is for crosvm itself to open an ADPF session over its
+ * own render threads and report real frame durations, which is where per-frame accuracy would
+ * come from.)
+ */
+public final class GamePerfHint {
+ private static final String TAG = "GamePerfHint";
+
+ private GamePerfHint() {
+ }
+
+ /** Declares sustained heavy gameplay (a VM display is in the foreground and rendering). */
+ public static void enterGameplay(@NonNull Context context) {
+ setState(context, GameState.MODE_GAMEPLAY_UNINTERRUPTIBLE, "gameplay");
+ }
+
+ /** Clears the declaration when no VM display is in the foreground anymore. */
+ public static void exitGameplay(@NonNull Context context) {
+ setState(context, GameState.MODE_NONE, "none");
+ }
+
+ private static void setState(@NonNull Context context, int mode, @NonNull String what) {
+ // GameState landed in API 33, which is also our minSdk; keep the guard so a lower-API
+ // build (or a stripped OEM image without the service) degrades to a no-op.
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return;
+ try {
+ var manager = context.getSystemService(GameManager.class);
+ if (manager == null) return;
+ manager.setGameState(new GameState(false, mode));
+ Log.i(TAG, fmt("declared game state: %s", what));
+ } catch (Exception e) {
+ // Not fatal: without it we simply run at whatever clocks the governor picks.
+ Log.w(TAG, fmt("failed to declare game state %s", what), e);
+ }
+ }
+}
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/perf/SystemGestureGuard.java b/app/src/main/java/cn/classfun/droidvm/lib/perf/SystemGestureGuard.java
new file mode 100644
index 00000000..52db9bce
--- /dev/null
+++ b/app/src/main/java/cn/classfun/droidvm/lib/perf/SystemGestureGuard.java
@@ -0,0 +1,94 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
+package cn.classfun.droidvm.lib.perf;
+
+import static cn.classfun.droidvm.lib.utils.StringUtils.fmt;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import cn.classfun.droidvm.lib.run.RunContext;
+import cn.classfun.droidvm.lib.run.RunResult;
+import cn.classfun.droidvm.lib.run.root.RootRunContext;
+
+/**
+ * Suppresses OEM full-screen touch gestures while a VM display is in the foreground, so
+ * multi-finger input reaches the guest instead of the host system.
+ *
+ * Why this exists: ColorOS/OxygenOS intercepts three-finger touches globally -- swipe-down
+ * takes a screenshot, touch-and-hold starts a partial screenshot -- and unlike the navigation
+ * back gesture there is NO public per-app opt-out ({@code setSystemGestureExclusionRects} only
+ * covers screen-edge gestures, and the game-mode declaration in {@link GamePerfHint} does not
+ * suppress it either). A guest desktop, however, has its own three-finger gestures (pinch zoom,
+ * workspace switch), which the host eats before the guest ever sees a pointer event.
+ *
+ * So: while (and only while) the native display is foreground, the OEM toggles below are
+ * turned off through the root shell, and restored to their previous values on exit. On devices
+ * without these keys (`settings get` prints "null") this is a no-op, so calling it
+ * unconditionally on every device is safe.
+ *
+ * If the app process dies while the display is up, the exit path never runs and the user's
+ * gesture setting stays off until the next display session restores it on entry -- an accepted
+ * trade-off for not persisting state; the keys are re-read (not assumed) on every enter.
+ */
+public final class SystemGestureGuard {
+ private static final String TAG = "SystemGestureGuard";
+
+ /** OEM gesture toggles (system namespace) that swallow multi-finger touches. */
+ private static final String[] KEYS = {
+ // ColorOS/OxygenOS "smart apperceive" screenshot: three-finger swipe & touch-and-hold.
+ "oplus_customize_smart_apperceive_screen_capture",
+ // Three-finger sideways swipe to switch apps.
+ "oplus_customize_three_fingers_switch_app",
+ };
+
+ /** Serializes enter/exit so a fast pause/resume cannot interleave get and put. */
+ private static final ExecutorService executor = Executors.newSingleThreadExecutor();
+
+ /** Keys that were "1" on enter and must go back to "1" on exit. */
+ private static final List It exists because of where crosvm lives. The VM is a child of the root daemon, which is an
+ * {@code app_process} started through su and completely outside the Android lifecycle --
+ * ActivityManager does not know it exists, so nothing it does can affect its uid's process state.
+ * But the host APIs those peripherals reach are gated on exactly that: AppOps resolves a
+ * foreground-only permission by asking whether the uid carries the matching
+ * {@code PROCESS_CAPABILITY_FOREGROUND_*}, which only a process ActivityManager manages can
+ * supply. crosvm runs setuid to the app's uid, so a foreground service in the app process is what
+ * lets it through -- measured: an unmanaged setuid'd process gets frames precisely while some
+ * other process of the same uid is foreground, and ERROR_CAMERA_DISABLED otherwise. Nothing here names a kind of peripheral. The type mask comes from
+ * {@code PeripheralType.getForegroundServiceType}, so a device that starts needing this only has
+ * to say so there. Called from the daemon, which is uid 0: {@code ActiveServices} exempts a root caller by
+ * app id, and the background-start check seeds itself from that same verdict, so this works
+ * with no app process in the foreground and no UI open. An app-process caller would be
+ * refused in exactly that case, which is why the decision does not live there. Pure by design: it reads no files itself. The caller supplies the {@link BackingLookup},
+ * which is what makes the whole plan testable and lets the export UI predict, with the same
+ * code, exactly what the daemon will pack.
+ */
+public final class DiskChainPlan {
+ /**
+ * Chain length cap. Far above the depth the overlay UI allows (see {@code DiskTree}); this
+ * is only here so a corrupt header cannot spin the walk forever, hence the loud failure
+ * rather than a silent truncation - a truncated chain is exactly the broken package this
+ * class exists to prevent.
+ */
+ public static final int MAX_CHAIN = 64;
+
+ private DiskChainPlan() {
+ }
+
+ /** Resolves one image's backing file to an absolute path, or null when it has none. */
+ public interface BackingLookup {
+ @Nullable
+ String backingOf(@NonNull String path) throws Exception;
+ }
+
+ /** One file the package has to carry. */
+ public static final class Member {
+ /** Absolute path on the exporting device. */
+ public final String path;
+ /** Name inside the archive; unique across the package. */
+ public final String archivePath;
+ /**
+ * The VM disk slot this file fills, or null when it is in the package only because
+ * something else backs onto it. A file can start out as a backing image and turn out to
+ * be an attached disk as well, which is why this is not final.
+ */
+ @Nullable
+ public DiskRef attachment = null;
+ /** {@link #archivePath} of this file's own backing image, or "" when it has none. */
+ public String backingArchive = "";
+
+ private Member(@NonNull String path, @NonNull String archivePath) {
+ this.path = path;
+ this.archivePath = archivePath;
+ }
+ }
+
+ /**
+ * Expand {@code tops} - the VM disks the user chose, in slot order - into every file the
+ * package needs.
+ *
+ * @throws IOException when a chain loops or runs deeper than {@link #MAX_CHAIN}; whatever
+ * {@code lookup} throws for an unreadable image or a missing backing file propagates as
+ * it is, so the export fails with the path that caused it.
+ */
+ @NonNull
+ public static List Adding a feature is one constant here plus the {@link Carrier} that reports using it. The
+ * version arithmetic never changes and no feature has to know about any other, which is the
+ * point: a chain of "if this then 3, else if that then 2" would have to be kept in the right
+ * order by hand, and a wrong order silently stamps a package lower than it needs.
+ */
+public enum ManifestFeature {
+ /**
+ * Files the VM does not attach, and overlay-to-base links ({@code attached} and
+ * {@code backing_archive} on a disk entry). A reader without it attaches every file as a
+ * disk and never re-points the overlays at their copied bases.
+ */
+ BACKING_CHAIN(2);
+
+ /** The manifest version that introduced the feature. */
+ public final int since;
+
+ ManifestFeature(int since) {
+ this.since = since;
+ }
+
+ /** A part of a manifest that can use features; it says which ones it actually does. */
+ public interface Carrier {
+ void collectFeatures(@NonNull Set The screen and the import task share this class so that what the user is shown is what gets
+ * built: the same rules pick which networks may be joined, decide whether creating is possible at
+ * all, and settle the name a created network ends up with.
+ */
+public final class NetworkImportPlan {
+ /** The manifest field carrying a packaged network's reference key. */
+ public static final String REF_KEY = "pkg_network_ref";
+
+ private final List Only networks of the same kind qualify: joining is what carries the packaged VM's
+ * kind-specific settings across intact -- an L3 network's DHCP pool offsets, a gVisor
+ * network's IPv6 SNAT -- and none of that survives being attached to a network built the
+ * other way. An empty list means this package's network has nothing here to join.
+ */
+ @NonNull
+ public List Claims the names it hands out, so calling this once per network being created gives each
+ * of them a different one.
+ */
+ @NonNull
+ public NetworkConfig prepareCreate(@NonNull NetworkConfig packaged) {
+ NetworkConfig cfg;
+ try {
+ cfg = new NetworkConfig(packaged.toJson());
+ } catch (JSONException e) {
+ throw new IllegalArgumentException("packaged network is not serializable", e);
+ }
+ cfg.item.remove(REF_KEY);
+ cfg.item.remove("id");
+ return adopt(cfg);
+ }
+
+ /**
+ * Settles a config that is already meant to be created here: a free id, and names that are
+ * still free. Applied to what the screen prepared as well, because the two run against their
+ * own copies of the store and a network may have appeared in between -- an import that
+ * renames one network too many is a great deal better than one that fails on a duplicate.
+ *
+ * Mutates and returns {@code cfg}, and claims what it hands out.
+ */
+ @NonNull
+ public NetworkConfig adopt(@NonNull NetworkConfig cfg) {
+ var id = cfg.item.optString("id", "");
+ if (id.isEmpty() || takenIds.contains(id)) {
+ id = UUID.randomUUID().toString();
+ cfg.setId(id);
+ }
+ takenIds.add(id);
+ var name = uniqueName(cfg.getName());
+ cfg.setName(name);
+ takenNames.add(name);
+ var bridge = cfg.getBridgeName();
+ if (bridge != null && !bridge.isEmpty()) {
+ var unique = uniqueBridge(bridge);
+ cfg.setBridgeName(unique);
+ takenBridges.add(unique);
+ }
+ return cfg;
+ }
+
+ /** {@code base} or the first free {@code base_N}. */
+ @NonNull
+ private String uniqueName(@Nullable String base) {
+ var name = base == null || base.trim().isEmpty() ? "network" : base;
+ if (!takenNames.contains(name)) return name;
+ for (int i = 1; ; i++) {
+ var candidate = fmt("%s_%d", name, i);
+ if (!takenNames.contains(candidate)) return candidate;
+ }
+ }
+
+ /**
+ * {@code base} or the first free {@code baseN}, trimmed so the suffix still fits the
+ * interface-name cap -- a bridge name over it is refused outright, so growing one past it to
+ * dodge a duplicate would only trade a collision for a rejection.
+ */
+ @NonNull
+ private String uniqueBridge(@NonNull String base) {
+ if (!takenBridges.contains(base)) return base;
+ for (int i = 1; i < 100000; i++) {
+ var suffix = String.valueOf(i);
+ int room = NetworkConfigValidator.MAX_BRIDGE_NAME_LEN - suffix.length();
+ var head = base.length() > room ? base.substring(0, Math.max(1, room)) : base;
+ var candidate = fmt("%s%s", head, suffix);
+ if (!takenBridges.contains(candidate)) return candidate;
+ }
+ return base;
+ }
+
+ /**
+ * How far a candidate is from the packaged network, lower being closer: for L2 the uplink it
+ * bridges, for L3 the primary IPv4 prefix, falling back to IPv6 when the packaged network has
+ * no IPv4 of its own. The point is that the network the user most likely means -- the same
+ * segment, the same uplink, carried over from the other phone -- is the one already selected.
+ */
+ private static long distance(@NonNull NetworkConfig packaged, @NonNull NetworkConfig other) {
+ if (packaged.getUplinkMode() == UplinkMode.L2) {
+ var mine = packaged.getL2Uplink();
+ var theirs = other.getL2Uplink();
+ if (mine == null || theirs == null) return 1000;
+ if (mine.trim().equalsIgnoreCase(theirs.trim())) return 0;
+ return 1000 - commonChars(mine, theirs);
+ }
+ var mine4 = primaryV4(packaged);
+ if (mine4 != null) {
+ var theirs4 = primaryV4(other);
+ return theirs4 == null ? 1000 : 32 - commonBits4(mine4, theirs4);
+ }
+ var mine6 = primaryV6(packaged);
+ if (mine6 != null) {
+ var theirs6 = primaryV6(other);
+ return theirs6 == null ? 1000 : 128 - commonBits6(mine6, theirs6);
+ }
+ return 500;
+ }
+
+ /** The first IPv4 network this config addresses, untagged VLAN first. */
+ @Nullable
+ private static IPv4Network primaryV4(@NonNull NetworkConfig cfg) {
+ IPv4Network first = null;
+ for (var vlan : cfg.getVlans()) {
+ var net = vlan.getIpv4Network();
+ if (net == null) continue;
+ if (vlan.isUntagged()) return net;
+ if (first == null) first = net;
+ }
+ return first;
+ }
+
+ @Nullable
+ private static IPv6Network primaryV6(@NonNull NetworkConfig cfg) {
+ IPv6Network first = null;
+ for (var vlan : cfg.getVlans()) {
+ var net = vlan.getIpv6Network();
+ if (net == null) continue;
+ if (vlan.isUntagged()) return net;
+ if (first == null) first = net;
+ }
+ return first;
+ }
+
+ private static long commonBits4(@NonNull IPv4Network a, @NonNull IPv4Network b) {
+ long diff = a.networkAddress().value() ^ b.networkAddress().value();
+ int bits = 0;
+ for (int i = 31; i >= 0 && ((diff >> i) & 1L) == 0; i--) bits++;
+ return bits;
+ }
+
+ private static long commonBits6(@NonNull IPv6Network a, @NonNull IPv6Network b) {
+ var diff = a.networkAddress().value().xor(b.networkAddress().value());
+ int bits = 0;
+ for (int i = 127; i >= 0 && !diff.testBit(i); i--) bits++;
+ return bits;
+ }
+
+ private static long commonChars(@NonNull String a, @NonNull String b) {
+ int n = Math.min(a.length(), b.length());
+ int i = 0;
+ while (i < n && Character.toLowerCase(a.charAt(i)) == Character.toLowerCase(b.charAt(i)))
+ i++;
+ return i;
+ }
+}
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageConstants.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageConstants.java
index c45a8af7..43e1f624 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageConstants.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageConstants.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.pkg;
import cn.classfun.droidvm.lib.archive.Compression;
@@ -8,7 +11,13 @@ public final class PackageConstants {
public static final String MAGIC = "VMPKG";
public static final int HEADER_SIZE = 24;
public static final int BUFFER = 64 * 1024;
- public static final int MANIFEST_VERSION = 1;
+ // BASE is the manifest of a package that uses none of the ManifestFeatures - the original
+ // one-file-per-disk layout. Every later addition is a ManifestFeature carrying the version
+ // that introduced it; a package is stamped with the highest one it uses, and what this
+ // build can read follows from the list rather than from a constant kept in step by hand.
+ // Readers accept anything up to MANIFEST_VERSION and refuse what is newer.
+ public static final int MANIFEST_VERSION_BASE = 1;
+ public static final int MANIFEST_VERSION = ManifestFeature.latest();
public static final String MANIFEST_NAME = "manifest.json";
public static final Compression DEFAULT_COMPRESSION = Compression.ZSTD;
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageHeader.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageHeader.java
index 64956cf5..4f89a879 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageHeader.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageHeader.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.pkg;
import static java.nio.charset.StandardCharsets.UTF_8;
@@ -40,7 +43,9 @@ public void parseFromData(@NonNull byte[] hdr) throws IOException {
}
public void validate() throws IOException {
- if (manifestVersion != PackageConstants.MANIFEST_VERSION)
+ // Older packages still import; newer ones are refused rather than half-understood.
+ if (manifestVersion < PackageConstants.MANIFEST_VERSION_BASE
+ || manifestVersion > PackageConstants.MANIFEST_VERSION)
throw new IOException(fmt("unsupported vmpkg manifest version: %d", manifestVersion));
if (Compression.fromType(compression) == null)
throw new IOException(fmt("unsupported vmpkg compression: %d", compression));
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageInput.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageInput.java
index 397c2b1e..daac35f2 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageInput.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageInput.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.pkg;
import static cn.classfun.droidvm.lib.archive.TarWriter.wrapCompressionInput;
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageManifest.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageManifest.java
index eaa90096..8f906f8f 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageManifest.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/PackageManifest.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.pkg;
import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum;
@@ -16,7 +19,9 @@
import java.io.InputStream;
import java.util.ArrayList;
+import java.util.EnumSet;
import java.util.List;
+import java.util.Set;
import cn.classfun.droidvm.BuildConfig;
import cn.classfun.droidvm.lib.archive.Compression;
@@ -25,7 +30,7 @@
import cn.classfun.droidvm.lib.store.vm.VMConfig;
public final class PackageManifest implements JSONSerialize {
- public int manifestVersion = PackageConstants.MANIFEST_VERSION;
+ public int manifestVersion = PackageConstants.MANIFEST_VERSION_BASE;
public String format = PackageConstants.EXTENSION;
public long createdAt = System.currentTimeMillis();
public String appVersion = BuildConfig.VERSION_NAME;
@@ -55,6 +60,23 @@ public JSONObject toJson() throws JSONException{
return o;
}
+ /** The {@link ManifestFeature}s this package's contents actually use. */
+ @NonNull
+ public Set For a set of choices that is easier to read whole than pruned -- a ladder whose upper
+ * rungs are designed but not built, say. Hiding them makes the remaining values look like the
+ * entire vocabulary and makes each one that lands later look like a feature out of nowhere;
+ * listing them greyed, with {@code note} saying why, says what the set is and where this build
+ * stands in it. Refused means refused from every direction, a stored config included: a value the picker
+ * will not let the user pick is not one it will sit on and hand back to save(). A selection
+ * this call refuses moves to {@link #setDefaultItem the default}, the same way
+ * {@link #setSelectedItem} answers one. The item stays listed, so the set still reads whole --
+ * what it no longer does is leave a VM quietly pointed down a path this build cannot take. Cleared by {@link #setItems} and {@link #autoItems}, since a new item set has its own
+ * answer -- the same constant can be reachable under one and not under another. Stored values outlive the sets that produced them. An option gets retired between
+ * releases; several rows build their item set out of another row's value, so a config written
+ * under one combination is routinely read back under another. Answering that with an exception
+ * made every restore path a crash waiting for the first user whose VM predates the current
+ * build -- which is what it was: an old VM naming a GL provider, under a backend whose set no
+ * longer lists one, took the editor down as it opened. So an item this picker does not list, or lists only to refuse (see
+ * {@link #setDisabledItems}), lands on {@link #setDefaultItem the default} and the call says
+ * so. A caller restoring a config does not have to work out which values belong to the set it
+ * just installed -- the set already knows, and that is the one copy of the rule. Worth naming wherever the head of the list is not the sensible answer -- a ladder whose
+ * bottom rung is the safe one but whose default is the highest rung this build reaches, say.
+ * Falling back to the head there would answer a value the build cannot honour with the slowest
+ * thing it can do, a downgrade the user never asked for and would have no way to notice. Set it after the {@link #setItems} that installs the set: a new set resets this along
+ * with everything else that was true of the old one. Rotation is the one way in that has no menu to grey a row out in, so the skip has to
+ * happen here. Without it the gesture walks onto values the dialog and the popup both refuse,
+ * which is the same picker answering the same question two ways. An address conflict is a conflict only where both networks are actually seen by the same
+ * stack. Two Linux bridges route in the host kernel, so their prefixes must not overlap; two
+ * gVisor networks collide the same way inside their own user-space stacks. A Linux bridge and a
+ * gVisor network never see each other's routes at all -- gVisor's addressing lives entirely in
+ * its own process, the kernel has no idea the prefix exists -- so the same subnet on both is
+ * fine, and refusing it only costs the user address space for no reason. An L2 network has no
+ * prefix to conflict with in the first place; what it cannot share is the physical uplink it
+ * bridges onto, which one network at a time owns.
+ *
+ * Names are the exception and are deliberately not scoped here: the display name and the
+ * bridge interface name stay unique app-wide across every kind, because they name a thing the
+ * user picks from one list and the host resolves in one namespace. Those are the store's
+ * {@code isNameUnique} / {@code isBridgeNameUnique}.
+ */
+public final class NetworkConflicts {
+ private NetworkConflicts() {
+ }
+
+ /** What collided. */
+ public enum Kind {
+ IPV4,
+ IPV6,
+ UPLINK,
+ }
+
+ /** One collision: what of ours hit what of theirs, and whose. */
+ public static final class Conflict {
+ @NonNull
+ public final Kind kind;
+ /** Our subnet / uplink, as text. */
+ @NonNull
+ public final String mine;
+ /** Theirs, as text. */
+ @NonNull
+ public final String theirs;
+ /** The network we collided with. */
+ @NonNull
+ public final NetworkConfig other;
+
+ Conflict(
+ @NonNull Kind kind,
+ @NonNull String mine,
+ @NonNull String theirs,
+ @NonNull NetworkConfig other
+ ) {
+ this.kind = kind;
+ this.mine = mine;
+ this.theirs = theirs;
+ this.other = other;
+ }
+
+ /** The other network's display name, never null for a message. */
+ @NonNull
+ public String otherName() {
+ var name = other.getName();
+ return name == null ? "" : name;
+ }
+ }
+
+ /**
+ * Whether a conflict between these two is even possible: same bridge type, and -- since an
+ * L2 network conflicts on its uplink and an L3 one on its prefixes -- same uplink mode. This
+ * is also exactly the set a packaged network may be imported into, so that every setting
+ * that is specific to a kind (L3 DHCP pool offsets, gVisor's IPv6 SNAT) carries over intact.
+ */
+ public static boolean sameKind(@NonNull NetworkConfig a, @NonNull NetworkConfig b) {
+ return a.getBridgeType() == b.getBridgeType()
+ && a.getUplinkMode() == b.getUplinkMode();
+ }
+
+ /** The first conflict between {@code cfg} and anything in the store, or null if it is free. */
+ @Nullable
+ public static Conflict find(
+ @NonNull NetworkConfig cfg,
+ @NonNull DataStore extends NetworkConfig> store,
+ @Nullable UUID exclude
+ ) {
+ return find(cfg, snapshot(store), exclude);
+ }
+
+ /** The same, against an explicit list. */
+ @Nullable
+ public static Conflict find(
+ @NonNull NetworkConfig cfg,
+ @NonNull List extends NetworkConfig> others,
+ @Nullable UUID exclude
+ ) {
+ var mine4 = new ArrayList These five travel together everywhere -- they are one decision seen from
+ * several sides, which {@link CpuPlacementPlan} explains -- so they cross the
+ * editor/dialog boundary as one value instead of five positional arguments.
+ * The affinity map is copied in, so a draft cannot be edited through the map
+ * the caller still holds.
+ */
+public final class CpuPlacementDraft {
+ /** vCPU index to the host cores it may run on; ordered, never null. */
+ @NonNull
+ public final Map The three crosvm flags this resolves are one decision seen from three
+ * sides, not independent knobs:
+ * Syntax matters here: crosvm's per-vCPU affinity form separates assignments
+ * with {@code ':'} and uses {@code ','} only inside one assignment's host set,
+ * i.e. {@code 0=4,5:1=6} means vCPU0 floats over host cores 4 and 5 while vCPU1
+ * is pinned to core 6. A vCPU absent from the map gets no mask at all.
+ */
+public final class CpuPlacementPlan {
+ public static final String KEY_AFFINITY = "cpu_affinity";
+ public static final String KEY_AUTO = "cpu_topology_auto";
+ public static final String KEY_CAPACITY = "cpu_capacity";
+ public static final String KEY_CLUSTERS = "cpu_clusters";
+ public static final String KEY_GPU_CGROUP = "gpu_cgroup_enabled";
+ public static final String KEY_GPU_CGROUP_PATH = "gpu_cgroup_path";
+ public static final String KEY_GPU_CGROUP_CPUS = "gpu_cgroup_cpus";
+
+ public static final String DEFAULT_GPU_CGROUP_PATH = "/dev/cpuset/gpuworker";
+ /** Separates clusters in the stored {@link #KEY_CLUSTERS} string. */
+ private static final String CLUSTER_SEP = ";";
+
+ /** vCPU index to the host cores it may run on; ascending, never null. */
+ @NonNull
+ public final Map Two conditions, because the switch alone was never the whole question: what
+ * {@code --gpu-cgroup-path} moves into the cpuset is the virtio-gpu device's worker threads,
+ * and a VM without that device has none. Emitting it there handed crosvm a flag with nothing
+ * to put in the group and left a directory on the host that no thread would ever join. The
+ * editor says the same thing in its own way -- the rows live inside the renderer section, so
+ * they grey out with the device -- but the stored switch outlives that, both from a config
+ * written before the device was turned off and from a file edited by hand. Every vCPU below {@code vcpuCount} lands in exactly one cluster: crosvm
+ * builds the guest {@code cpu-map} all-or-nothing, so a vCPU left out of the
+ * cluster list would get no topology placement at all while its siblings do.
+ * An unpinned vCPU floats across every host core, so the weakest cluster is
+ * both the truthful and the conservative home for it.
+ */
+ @NonNull
+ public static List Superseded by {@link VMScreenConfig} -- the two devices are independent screens now, and a
+ * VM can have both -- so nothing reads this except {@link VMScreenConfig#migrate}, which reads
+ * each old config's value exactly once and then drops the key. It is no longer a
+ * {@code StringEnum} and carries no labels: it is never shown, only decoded.
+ * A screen drives at most one exporter. That is a decision, not a limitation waiting to be
+ * lifted: the alternative to the old silent race (two sinks configured, VNC wins, the app's
+ * Surface never gets a binder) was either mirroring or an error, and this picks the error.
+ * crosvm enforces the same rule on its side and refuses to start a VM with two exporters on
+ * one screen, so the editor must never write one.
+ *
+ * The names are persisted, so they are the stable part; they say nothing about which screen
+ * the binding is on, because that is the key the binding is stored under.
+ */
+public enum DisplayExporter implements StringEnum {
+ // Declaration order is the picker's row order: EnumPicker.autoItems() walks
+ // getEnumConstants() and appends, with no comparator anywhere. So the two exporters lead and
+ // the sink comes last. Reordering is safe because nothing here is ordinal-shaped -- the
+ // stored value is name().toLowerCase(), nothing in the tree reads ordinal() or getValue() on
+ // this enum, and the picker's initial selection is set by configure(cls, value) rather than
+ // by which constant happens to be first.
+ NATIVE(1, "native", R.string.create_vm_screen_exporter_native),
+ VNC(2, "vnc", R.string.create_vm_screen_exporter_vnc),
+ // Unlike the NONE sentinels of the other persisted enums, this one is a real, selectable
+ // choice: a screen nobody is watching is a state, not a fault -- crosvm accepts it too. So
+ // it carries a label instead of R.string.nullptr; a nullptr entry reports isDisplay() ==
+ // false and EnumPicker.autoItems() would drop it from the picker. The label says sink rather
+ // than none because the screen still exists and still produces frames; they go nowhere.
+ NONE(0, "none", R.string.create_vm_screen_exporter_none);
+
+ private final int value;
+ private final String name;
+ private final @StringRes int stringId;
+
+ DisplayExporter(int value, String name, @StringRes int stringId) {
+ this.value = value;
+ this.name = name;
+ this.stringId = stringId;
+ }
+
+ @SuppressWarnings("unused")
+ public int getValue() {
+ return value;
+ }
+
+ @SuppressWarnings("unused")
+ public String getName() {
+ return name;
+ }
+
+ @Override
+ @StringRes
+ public int getStringId() {
+ return stringId;
+ }
+
+ @Override
+ public boolean isDisplay() {
+ return stringId != R.string.nullptr;
+ }
+}
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/DisplayTransportCap.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/DisplayTransportCap.java
new file mode 100644
index 00000000..8837fd44
--- /dev/null
+++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/DisplayTransportCap.java
@@ -0,0 +1,278 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
+package cn.classfun.droidvm.lib.store.vm;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.StringRes;
+
+import cn.classfun.droidvm.R;
+import cn.classfun.droidvm.lib.store.enums.StringEnum;
+
+/**
+ * How far up the pipeline one screen's frames are allowed to travel to reach its exporter.
+ *
+ * The transport is not a property of the screen or of the exporter but of the edge between
+ * them, and it is negotiated: the source says what it can produce (CPU bytes, a dmabuf), the
+ * exporter says what it can consume, and the highest rung both reach wins. So it is not a thing
+ * the user picks outright. What the user can have is a ceiling, and that distinction is
+ * the whole reason this enum reads the way it does. It only restricts downward. The transport is settled at or below the rung named here,
+ * so every value is always satisfiable -- CPU copy is the bottom of the ladder and needs nothing
+ * from either end. "At least GPU copy" would not be: a source that cannot export a dmabuf leaves
+ * only a silent downgrade (which looks like success and is not) or a loud failure (a VM that
+ * refuses to start over a preference). A ceiling has neither failure mode. It is also why there is
+ * no separate "automatic" entry -- the top rung of the offered set already is automatic. The ladder is not the same on every edge, because the rung above a copy is a different
+ * mechanism on each. The native display can be lent a render target the guest draws straight into
+ * ({@link #ZERO}) -- but only when the guest's rendering can be aimed at it, which rules simplefb
+ * out: its framebuffer is a fixed window of guest memory named in the device tree, and no
+ * AHardwareBuffer can be made to wrap that. VNC has nowhere to lend a target at all, and its rung
+ * above a GPU blit is handing the frame to a hardware video encoder instead of an RFB rectangle
+ * ({@link #GPU_HW}). So the offered set is a function of both ends, and {@link #optionsFor} is
+ * where that lives. Rungs that are designed but not built are offered and refused rather than hidden: the ladder
+ * is easier to understand whole, and a value that appears later must not look like a new feature
+ * arriving out of nowhere. {@link #isImplemented} says which is which today. The blit imports the frame as a LINEAR dma-buf and turnip accepts one only when its row
+ * pitch is 64-byte aligned. A virtio-gpu scanout is allocated by the host and rounded up to
+ * whatever the importer wants, so it never meets this rule by accident -- it meets it by
+ * construction. simplefb's framebuffer is a window of guest memory the device tree already
+ * described, {@code width * 4} bytes per row with nothing to pad it with, so there the whole
+ * rule collapses to {@code width * 4 % 64 == 0}, which is this. Measured on device: 1400 falls
+ * back to the CPU copy, 1408 does not. Both ends decide it, which is why the screen is a parameter. The native display's top
+ * rung is a render target it lends the guest to draw into -- and simplefb cannot be drawn into
+ * that way at all: its framebuffer is a fixed window of guest memory the guest was told about
+ * in the device tree, and an AHardwareBuffer cannot be made to wrap it. That rung is not
+ * "unbuilt" there, it is unreachable, so it is absent rather than greyed. Offering it would be
+ * describing a choice nobody will ever be able to make. An exporter with no edge -- nobody is watching the screen -- has no ladder, and the empty
+ * array is how callers know not to offer one rather than having to ask the question twice. VNC's ladder is now built to the top: the same blit that feeds an RFB rectangle can feed a
+ * hardware H.264 encoder instead, and the app's own console reads the result off a side channel
+ * beside the RFB port. What is left unbuilt is zero copy on the native display. Unimplemented
+ * rungs are still offered -- see the class comment -- so this is what decides which of them the
+ * picker refuses. The two exporters no longer answer the same way, which is the point: they climbed to
+ * different heights by different mechanisms, and writing that as one shared list of caps would
+ * have made the day VNC overtook the native display look like a typo. This is the negotiation working exactly as designed -- the ceiling only restricts
+ * downward, so nothing here is a misconfiguration and nothing needs refusing. But it is the one
+ * downgrade whose cause is a number the user typed rather than a rung the build has not
+ * reached, so it is the one worth saying out loud in the editor: a width off by eight pixels
+ * costs the whole GPU path and there is no other way to find that out. Only simplefb has the constraint, and only where the ceiling actually asks for a blit --
+ * see {@link #GPU_COPY_WIDTH_ALIGN}. Asking {@link #isImplemented} rather than naming the
+ * native display is what made VNC's GPU half inherit the rule the day it landed: it imports the
+ * same dma-buf under the same 64-byte pitch rule, and this condition did not have to be found
+ * and changed for the warning to start appearing there. The encoder rung asks for the same import -- it is the same blit with a different
+ * destination -- so it is named here too. It had to be: the moment VNC's default rose to it,
+ * a condition that only knew about {@link #GPU} would have gone quiet for exactly the
+ * configuration it was written for, and a warning that disappears when the default moves is
+ * indistinguishable from one that was never right. Not the highest rung offered -- that would default every VM to a ceiling nothing can
+ * satisfy today, which is a promise the negotiation would quietly break. It is the highest
+ * implemented one, so the default never restricts anything that works, and it rises
+ * on its own as the rungs land. Which is how VNC's default became the hardware encoder, and that reads more
+ * expensive than it is. A ceiling is not a request: the encoder is built when a client opens
+ * the H.264 side channel and never otherwise, so a VM at this default that nobody watches over
+ * that channel does exactly what the same VM did at the GPU rung -- one blit, an RFB rectangle,
+ * no encoder. Every ordinary RFB client keeps working unchanged; what the top rung buys is that
+ * the app's own console can ask for H.264 instead of pixels. A ceiling at the top of what this build can reach is the same instruction as no ceiling at
+ * all, so the flag is written only when it says something the host would not work out on its
+ * own: the user asked for less than the pipeline could have given. The absence of the
+ * flag is therefore not "unspecified", it is the top rung -- which is also what makes the
+ * default configuration emit nothing, on either exporter. Position in {@link #optionsFor}, not the enum's own order, decides what "below" means. The
+ * two ladders diverge above the blit -- {@link #ZERO} on one, {@link #GPU_HW} on the other --
+ * so an ordinal comparison would be comparing rungs from different ladders. It also keeps a
+ * ceiling stored under another exporter from ever being emitted: {@link VMScreenConfig} has
+ * already resolved such a value to this edge's default, and this refuses to name anything the
+ * edge does not offer. Declaration order is menu order (the picker walks the constants), so it is a UI decision, not
+ * a storage one -- the config carries the constant's name ({@code Enums.optEnum}), never its
+ * position, so reordering here does not touch a stored VM. The Vulkan providers are peers, chosen and gated by the same rule: a provider is usable when
+ * it exposes the raw-dmabuf-import extensions the blit needs (VK_EXT_external_memory_dma_buf et
+ * al.). Turnip is not special -- it is simply the provider that passes on Adreno; the current
+ * platforms just happen to all be Qualcomm. {@link #SYSTEM} defers to the SoC's stock driver
+ * (offered only where a capability probe passes) and {@link #PANVK} to Mesa PanVK on Mali (not
+ * wired yet). {@link #OFF} forces crosvm's CPU copy (GPU_DISPLAY_COPY_MODE=cpu), the universal
+ * path every compositor accepts.
+ *
+ * On any Vulkan provider the crosvm bridge probes the extensions itself and falls back to the
+ * CPU copy if they are missing, so a wrong choice degrades rather than breaks.
+ *
+ * Persisted as {@code display_blit_provider}; only meaningful with the native display on the
+ * virtio-gpu backend.
+ */
+public enum GpuBlitProvider implements StringEnum {
+ TURNIP(0, "turnip", R.string.create_vm_gpu_api_vulkan_turnip),
+ PANVK(1, "panvk", R.string.create_vm_gpu_api_vulkan_panvk),
+ SYSTEM(2, "system", R.string.create_vm_gpu_api_vulkan_system),
+ OFF(3, "off", R.string.create_vm_display_blit_off);
+
+ private final int value;
+ private final String name;
+ private final @StringRes int stringId;
+
+ GpuBlitProvider(int value, String name, @StringRes int stringId) {
+ this.value = value;
+ this.name = name;
+ this.stringId = stringId;
+ }
+
+ @SuppressWarnings("unused")
+ public int getValue() {
+ return value;
+ }
+
+ @SuppressWarnings("unused")
+ public String getName() {
+ return name;
+ }
+
+ @NonNull
+ @SuppressWarnings("unused")
+ public static GpuBlitProvider fromValue(int value) {
+ for (var v : values())
+ if (v.value == value) return v;
+ return TURNIP;
+ }
+
+ /** True for the providers that are wired today. Only PanVK is still unbuilt. */
+ public boolean isImplemented() {
+ return this != PANVK;
+ }
+
+ @Override
+ @StringRes
+ public int getStringId() {
+ return stringId;
+ }
+}
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuMode.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuMode.java
new file mode 100644
index 00000000..caa75e59
--- /dev/null
+++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuMode.java
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
+package cn.classfun.droidvm.lib.store.vm;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+
+import cn.classfun.droidvm.R;
+import cn.classfun.droidvm.lib.store.enums.StringEnum;
+
+/**
+ * WHERE the guest is intercepted -- the virtio-gpu context type, in plain terms.
+ *
+ * This is the axis rutabaga actually has: a capset belongs to a renderer component, and the
+ * component decides how much of the guest's graphics work crosses the boundary.
+ *
+ * Persisted as {@code gpu_mode}; older configs carry the pre-split {@code gpu_api} and are
+ * migrated by {@link #fromLegacyApi}.
+ */
+public enum GpuMode implements StringEnum {
+ NONE(0, "none", R.string.nullptr),
+ OPENGL(1, "opengl", R.string.create_vm_gpu_mode_opengl),
+ VULKAN(2, "vulkan", R.string.create_vm_gpu_mode_vulkan),
+ NATIVE(3, "native", R.string.create_vm_gpu_mode_native);
+
+ private final int value;
+ private final String name;
+ private final @StringRes int stringId;
+
+ GpuMode(int value, String name, @StringRes int stringId) {
+ this.value = value;
+ this.name = name;
+ this.stringId = stringId;
+ }
+
+ @SuppressWarnings("unused")
+ public int getValue() {
+ return value;
+ }
+
+ @SuppressWarnings("unused")
+ public String getName() {
+ return name;
+ }
+
+ /** The mode implied by a pre-split {@code gpu_api} value. */
+ @NonNull
+ public static GpuMode fromLegacyApi(@NonNull GpuApi api) {
+ switch (api) {
+ case EGL:
+ case OPENGLES:
+ case ANGLE:
+ return OPENGL;
+ case VULKAN:
+ case VULKAN_SYSTEM:
+ case VULKAN_TURNIP:
+ case VULKAN_PANVK:
+ return VULKAN;
+ case DRM2KGSL:
+ return NATIVE;
+ default:
+ return NONE;
+ }
+ }
+
+ @Override
+ @StringRes
+ public int getStringId() {
+ return stringId;
+ }
+
+ @Override
+ public boolean isDisplay() {
+ return stringId != R.string.nullptr;
+ }
+}
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuProvider.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuProvider.java
new file mode 100644
index 00000000..be2a18dc
--- /dev/null
+++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuProvider.java
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
+package cn.classfun.droidvm.lib.store.vm;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.StringRes;
+
+import cn.classfun.droidvm.R;
+import cn.classfun.droidvm.lib.store.enums.StringEnum;
+
+/**
+ * WHICH host driver serves the proxied calls, once {@link GpuMode} has decided what is proxied.
+ *
+ * The two halves are the same question asked of two renderers, which is why they share a
+ * control: with {@link GpuMode#OPENGL} virglrenderer needs a host GL context, and with
+ * {@link GpuMode#VULKAN} both gfxstream and virglrenderer (venus) need a host Vulkan driver.
+ * "Vulkan on virglrenderer" IS venus -- the mode already says so -- so this row never names
+ * venus; it names the ICD venus dlopens: turnip, PanVK, or the SoC's stock HAL.
+ *
+ * {@link GpuMode#NATIVE} has one entry today, {@link #DRM2KGSL}. The row is still shown for
+ * it: which DRM backend answers is a real axis (msm on a drm/msm kernel, KGSL on Adreno's
+ * downstream one), this device just has a single answer, and leaving the row visible keeps the
+ * three levels legible instead of making NATIVE look like it has no host driver at all.
+ *
+ * Persisted as {@code gpu_provider}; older configs carry the pre-split {@code gpu_api} and
+ * are migrated by {@link #fromLegacyApi}.
+ */
+public enum GpuProvider implements StringEnum {
+ NONE(0, "none", R.string.nullptr),
+ // Host GL, for virglrenderer's OpenGL mode. Passed through as --gpu egl= / gles=.
+ //
+ // There is deliberately no ANGLE. GpuParameters carries no `angle` field and is declared
+ // #[serde(deny_unknown_fields)], so `--gpu ...,angle=true` does not enable anything -- it
+ // makes crosvm reject the whole --gpu argument and the VM never starts.
+ EGL(1, "egl", R.string.create_vm_gpu_api_egl),
+ GLES(2, "gles", R.string.create_vm_gpu_api_opengles),
+ // Host Vulkan, for GpuMode.VULKAN on either renderer (gfxstream, or venus on virglrenderer).
+ // Selects ANDROID_EMU_VK_LOADER_PATH, which both gfxstream's VulkanDispatch and venus's
+ // vkr_library honour: the SoC's stock Vulkan HAL, the bundled Mesa turnip (Adreno), or Mesa
+ // PanVK (Mali) -- PanVK is not wired yet.
+ VK_SYSTEM(4, "vulkan-system", R.string.create_vm_gpu_api_vulkan_system),
+ VK_TURNIP(5, "vulkan-turnip", R.string.create_vm_gpu_api_vulkan_turnip),
+ VK_PANVK(6, "vulkan-panvk", R.string.create_vm_gpu_api_vulkan_panvk),
+ // The DRM backend for GpuMode.NATIVE. virglrenderer receives the msm wire protocol and
+ // re-synthesises it as KGSL ioctls against the host's /dev/kgsl-3d0; the guest never sees
+ // a KGSL device of its own. Selects --gpu context-types=virgl2:drm.
+ DRM2KGSL(7, "drm2kgsl", R.string.create_vm_gpu_provider_drm2kgsl);
+ // Value 8 was a short-lived "venus" entry that put the proxy, not the host driver, in this
+ // row. Retired: venus is implied by virglrenderer + GpuMode.VULKAN, and configs that still
+ // carry gpu_provider=venus fall back through fromLegacyApi(VULKAN) to VK_TURNIP -- the
+ // driver they always ran on.
+
+ private final int value;
+ private final String name;
+ private final @StringRes int stringId;
+
+ GpuProvider(int value, String name, @StringRes int stringId) {
+ this.value = value;
+ this.name = name;
+ this.stringId = stringId;
+ }
+
+ @SuppressWarnings("unused")
+ public int getValue() {
+ return value;
+ }
+
+ @SuppressWarnings("unused")
+ public String getName() {
+ return name;
+ }
+
+ /** The provider implied by a pre-split {@code gpu_api} value. */
+ @NonNull
+ public static GpuProvider fromLegacyApi(@NonNull GpuApi api) {
+ switch (api) {
+ case EGL: return EGL;
+ case OPENGLES: return GLES;
+ // A saved ANGLE could never have booted (see above); land it on GLES rather than
+ // carrying a value that only fails.
+ case ANGLE: return GLES;
+ case VULKAN_SYSTEM: return VK_SYSTEM;
+ case VULKAN_TURNIP: return VK_TURNIP;
+ case VULKAN_PANVK: return VK_PANVK;
+ case DRM2KGSL: return DRM2KGSL;
+ // Plain VULKAN is what the retired "venus" provider wrote as gpu_api (and what the
+ // pre-venus virglrenderer `vulkan=true` did). Both ran on the bundled turnip -- the
+ // launcher's non-SYSTEM/PANVK default -- so name that explicitly.
+ case VULKAN: return VK_TURNIP;
+ default: return NONE;
+ }
+ }
+
+ @Override
+ @StringRes
+ public int getStringId() {
+ return stringId;
+ }
+
+ @Override
+ public boolean isDisplay() {
+ return stringId != R.string.nullptr;
+ }
+}
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GuestPoolSizing.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GuestPoolSizing.java
new file mode 100644
index 00000000..10ffc186
--- /dev/null
+++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GuestPoolSizing.java
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
+package cn.classfun.droidvm.lib.store.vm;
+
+import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum;
+
+import androidx.annotation.NonNull;
+
+import cn.classfun.droidvm.lib.store.base.DataItem;
+
+/**
+ * How much guest-owned VRAM pool a VM actually gets at boot, as one rule shared by the crosvm
+ * command builder (which passes it) and the huge-page preflight (which budgets for it). The two
+ * drifted once: the daemon zeroed the pool for host-visible-RAM modes while the preflight still
+ * added it, so a 5 GB pseudo-unprotected VM was told it needed 6 GB of reserve.
+ *
+ * The guest-alloc pool buys the host access to buffers the guest allocated, which in an
+ * ordinary protected VM it does not otherwise have. When the host can already reach the guest's
+ * RAM (an unprotected VM, or a pseudo-unprotected one whose window is shared back before the
+ * payload runs) the pool is memory taken from the guest to solve a problem that is not
+ * happening, so it is dropped. gfxstream additionally needs udmabuf, which is what gates
+ * guest-created handles; without it there is nothing to pre-allocate.
+ */
+public final class GuestPoolSizing {
+ private GuestPoolSizing() {
+ }
+
+ /** The host can read the guest's RAM directly, so no guest pool is passed. */
+ public static boolean hostVisibleRam(@NonNull DataItem item) {
+ var pvm = optEnum(item, "protected_vm", ProtectedVM.PROTECTED_WITHOUT_FIRMWARE);
+ return pvm == ProtectedVM.PROTECTED_NORMAL || pvm == ProtectedVM.PSEUDO_UNPROTECTED;
+ }
+
+ /** The pool window ({@code gpu-guest-mb}) crosvm will be given, 0 when none. */
+ public static long bootGuestPoolMb(@NonNull DataItem item) {
+ if (!VMScreenConfig.hasGpuDevice(item)) return 0;
+ if (hostVisibleRam(item)) return 0;
+ long pool = Math.max(item.optLong("gpu_guest_pool_mb", 0), 0);
+ var backend = optEnum(item, "gpu_backend", GpuBackend.NONE);
+ if (backend == GpuBackend.GPU_GFXSTREAM)
+ return item.optBoolean("gpu_udmabuf", true) ? pool : 0;
+ if (backend == GpuBackend.GPU_VIRGLRENDERER)
+ return pool;
+ return 0;
+ }
+
+ /**
+ * The part of that window pre-allocated at boot ({@code gpu-guest-prealloc-mb}) - what the
+ * huge-page reserve pays up front. Older configs carry no prealloc field and keep the whole
+ * pool preallocated; growth grants come later, one blob at a time, and are not counted.
+ */
+ public static long bootGuestPreallocMb(@NonNull DataItem item) {
+ long pool = bootGuestPoolMb(item);
+ if (pool <= 0) return 0;
+ return Math.max(Math.min(item.optLong("gpu_guest_prealloc_mb", pool), pool), 0);
+ }
+}
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/LendMthpMode.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/LendMthpMode.java
index d45676c4..2c4a8e63 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/LendMthpMode.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/LendMthpMode.java
@@ -1,11 +1,19 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.store.vm;
import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum;
+import android.content.Context;
+import android.util.Log;
+
import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import cn.classfun.droidvm.R;
+import cn.classfun.droidvm.lib.data.QcomChipName;
+import cn.classfun.droidvm.lib.data.QcomGunyahSupports;
import cn.classfun.droidvm.lib.store.base.DataItem;
import cn.classfun.droidvm.lib.store.enums.StringEnum;
@@ -16,6 +24,7 @@ public enum LendMthpMode implements StringEnum {
public static final String KEY = "prepare_lend_mthp";
public static final LendMthpMode DEFAULT = CHUNKED;
+ private static final String TAG = "LendMthpMode";
private final @StringRes int stringId;
@@ -38,4 +47,28 @@ public static LendMthpMode fromItem(@NonNull DataItem item) {
return raw.asBoolean() ? CHUNKED : DISABLED;
return optEnum(item, KEY, DEFAULT);
}
+
+ /**
+ * Device-aware default used by every new-VM path. Keep the priority identical to the
+ * capability table: a more specific supported mode later in the list wins. In particular,
+ * Snapdragon 8 Gen 3 advertises only {@code mthp_single} and must never default to chunked
+ * preallocation.
+ */
+ @NonNull
+ public static LendMthpMode defaultForDevice(@NonNull Context context) {
+ var mode = DEFAULT;
+ try {
+ var socModel = QcomChipName.getCurrentSoC();
+ var gunyah = new QcomGunyahSupports(context);
+ if (gunyah.isCapacitySupported(socModel, "no_mthp"))
+ mode = DISABLED;
+ if (gunyah.isCapacitySupported(socModel, "mthp_chunked"))
+ mode = CHUNKED;
+ if (gunyah.isCapacitySupported(socModel, "mthp_single"))
+ mode = SINGLE;
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to resolve device MTHP default", e);
+ }
+ return mode;
+ }
}
diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/NativeDisplay.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/NativeDisplay.java
index b0445760..36df5f7e 100644
--- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/NativeDisplay.java
+++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/NativeDisplay.java
@@ -1,3 +1,6 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright DroidVM contributors
+// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root.
package cn.classfun.droidvm.lib.store.vm;
import static cn.classfun.droidvm.lib.Constants.DATA_DIR;
@@ -6,20 +9,61 @@
import androidx.annotation.NonNull;
+import java.nio.charset.StandardCharsets;
+
/**
* Shared naming for the native (crosvm android-display) backend. The daemon (launching crosvm and
* hosting the native-display binder) and the UI (looking up the display binder / sending input)
- * both derive the per-VM service name and socket paths from here, so they agree and different VMs
- * never collide.
+ * both derive the service names and socket paths from here, so they agree and different VMs never
+ * collide.
+ *
+ * The names the outside world sees hang off one per-VM root, {@link #channelKeyFromId}: a screen's
+ * display service is that root plus the screen's id. The root is a pure function of the VM's UUID,
+ * which is why a crosvm that died and restarted re-registers under exactly the name the app is
+ * still waiting on.
+ *
+ * A display service is per screen, not per VM: two screens exporting natively at once would
+ * otherwise want one servicemanager name for two Surfaces, and a service holds two slots (main +
+ * cursor), not N.
+ *
+ * The input sockets split the same way, but not all of them: the relative pointer alone stays on
+ * the VM root, because it has no output binding at all -- the guest compositor routes it by focus
+ * and it walks from one output to the next. The multi-touch and absolute-pointer devices are per
+ * screen because an absolute coordinate only means anything under one output's geometry; the
+ * keyboard is per screen for a different reason, which is that input belongs to the scanout. A
+ * screen's input switch has to be able to turn typing off on that screen, and a VM-wide keyboard
+ * could not be turned off by any one screen -- so a keyboard the console types into is the
+ * console's screen's keyboard, and a screen with input off has none at all.
*
- * The service name doubles as the vmKey: it identifies both the servicemanager entry crosvm
- * registers (--android-display-service) and the VM's input-socket set.
+ * The socket filenames are the one set of names here that is not identity-bearing, and
+ * they are deliberately terse. A unix socket address holds 107 bytes of path plus a NUL, and this
+ * app's run directory already spends 35 of them ({@code /data/data/cn.classfun.droidvm/run/}); the
+ * old {@code droidvm_disp_ A channel is not by itself a device any more: {@link #isPerScreen} says whether the VM
+ * has one of them or one per screen, so the daemon binds a socket per (screen, channel) pair
+ * that exists rather than {@link #CHANNEL_COUNT} of them. The two absolute devices are: their coordinates are read against one output's geometry,
+ * so a VM with two screens needs two of each and the guest has to be told by hand which is
+ * which. The keyboard is too, on the different ground that input is a property of the scanout:
+ * the screen's input switch governs it, so it cannot be one device shared by screens that
+ * disagree about whether input is on. Only the relative pointer is left VM-wide -- it has no
+ * output binding, and the guest compositor sends it wherever focus is. This one predicate is what makes a channel per screen everywhere at once: it picks the
+ * socket filename ({@link #inputSocketPath}), the daemon's slot key, and therefore whether the
+ * screen the console names reaches the write at all. The UI already sends its screen id on
+ * every channel, so a channel moving across this line needs no change on that side. The daemon binds these and crosvm's {@code --input ...[path=]} connects to them, and the
+ * two sides agree because they call this one function rather than each composing the name and
+ * the directory themselves. Terse on purpose, and safe to be terse: unlike the service name and the evdev names, this
+ * string is born and dies inside one VM start -- see the class note for what the long form
+ * cost. Worst case here is 90 bytes of the 107 a unix socket address holds; the margin is
+ * asserted in the tests and enforced by {@link #requireBindablePath} at the bind. This exists because the two ends disagree about what to do with an over-long path, and
+ * both answers are bad. crosvm refuses the command line outright ("path must be shorter than
+ * SUN_LEN") and the VM never starts. bind(2) as this daemon reaches it does the opposite: the
+ * path is copied into a 108-byte {@code sun_path} and silently truncated, so the
+ * daemon binds some other inode, logs a successful pre-listen, and waits forever for a crosvm
+ * that was told the untruncated name -- run/ on the test phone still held two of those stubs,
+ * {@code ..._simplefb_input_multito} and {@code ..._simplefb_input_tablet.}, as the only trace
+ * that anything had gone wrong. So the length is checked here, before the syscall, and a name
+ * that grows past the limit hits a wall with the number in the message instead of a mystery. Measured in bytes, not chars: the kernel copies bytes, and {@link #sanitize} keeps the
+ * two equal only as long as every name it is fed is ASCII. Neither evdev nor HID has a field for "I belong to output N", so every guest OS maps a
+ * touchscreen to an output by the device's name: kwin stores it by name,
+ * {@code xinput map-to-output} takes it by name, Windows' Tablet PC setup remembers the one
+ * it was pointed at. That makes the name the only lever there is, and it has to be a pure
+ * function of the screen and never change -- rename it and the user's mapping silently stops
+ * matching anything, with no error to notice. Its absolute-pointer sibling is named by {@link #tabletDeviceName} for the same reason
+ * and on the same terms. Everything {@link #touchDeviceName} says applies here unchanged -- an absolute pointer is
+ * as much a per-output device as a touchscreen, and the guest maps it to an output by name in
+ * exactly the same places. It used to have no name at all, because crosvm's
+ * {@code absolute-mouse} option had no {@code name} field and its option enum rejects unknown
+ * keys, so the device fell back to crosvm's generated "Crosvm Virtio Absolute Mouse <idx>"
+ * -- an index that counts emission order and therefore moves when another screen's input is
+ * switched off, which is the one thing a mapping key must never do. crosvm takes the field
+ * now, so the tablet is pinnable on the same terms as the touchscreen. Cross-repo seam. This string is produced in two places. A natively exported screen's
+ * tablet is the {@code --input absolute-mouse} the daemon emits with this name. A VNC-exported
+ * screen's tablet is built by crosvm itself, behind that screen's VNC server, and crosvm names
+ * it by reproducing this format -- there is no command-line key carrying it, so the format is
+ * the contract. Changing the format here means changing crosvm's VNC device setup in the same
+ * breath; changing only one silently unpins every guest-side mapping on the other. Which
+ * screens get which is {@code CrosvmBackendInstance.nativeInputScreens}. The same seam applies
+ * to {@link #keyboardDeviceName}. A keyboard is not an absolute device and the guest binds no output to it, so unlike its
+ * two siblings this name is not what a mapping keys on. It is still derived per screen, for
+ * two reasons. The guest lists these side by side and a user looking at several identical
+ * "DroidVM Keyboard" entries cannot tell which screen's switch turns which one off; and the
+ * name is the only thing distinguishing them, since a keyboard advertises nothing else that
+ * differs. Cross-repo seam, on the same terms as {@link #tabletDeviceName} and in both
+ * directions: the daemon emits this string in {@code --input keyboard[...,name=]} for a
+ * natively exported screen, and crosvm builds a VNC-exported screen's keyboard itself and
+ * names it by reproducing this format. Two producers, one format, and no command-line key
+ * carrying it between them. An offset is what a static lease actually stores: the host part, counted from the VLAN's
+ * network address, so the lease survives the network being re-addressed. Two NICs on the same
+ * VLAN holding the same offset would be handed the same IP, so an offset is allocated against
+ * everything already on that VLAN -- every other VM's NICs, and the VM's own other NICs.
+ *
+ * Pure arithmetic over configs: no store, no context, no side effects. Callers decide where
+ * the VMs come from and what to do with the answer.
+ */
+public final class NicLeaseOffsets {
+ /** Static leases start here, leaving 1..63 for whatever the host wants at the low end. */
+ public static final long FIRST = 64;
+ /** Cap on how far a search walks before it gives up, so a huge VLAN cannot hang it. */
+ private static final long MAX_PROBES = 1L << 16;
+
+ private NicLeaseOffsets() {
+ }
+
+ /** Which address family's lease is meant. */
+ public enum Family {
+ IPV4,
+ IPV6,
+ }
+
+ /**
+ * The offsets one VM's NICs hold on this network/VLAN, appended to {@code used}. Callers walk
+ * their own store: everything on the VLAN counts, including the resolving VM's other NICs, so
+ * that two NICs resolved in one pass cannot land on the same offset.
+ */
+ public static void addOffsets(
+ @NonNull Set Searching upward from what was asked for, rather than from {@link #FIRST}, is what keeps
+ * an imported VM's addresses recognisable: a package whose NICs sat at .70 and .71 lands on
+ * .70 and .71 again unless something is already there, and only drifts by as much as it has
+ * to.
+ */
+ public static long resolve(
+ long wanted,
+ @NonNull Set
+ *
+ *
+ *
+ *
+ * The last two are keyed by guest vCPU index and their correct values
+ * follow from the first plus host topology, which is why {@link #KEY_AUTO}
+ * (default on) derives them and the manual fields only exist as an override.
+ *
+ * > clusters;
+
+ private CpuPlacementPlan(
+ @NonNull Map
> clusters
+ ) {
+ this.affinity = affinity;
+ this.capacity = capacity;
+ this.clusters = clusters;
+ }
+
+ /**
+ * Resolve a stored config into the placement actually to be applied. An
+ * empty affinity string yields an empty plan: capacity and clusters are
+ * dropped along with it, since without knowing which host core backs a vCPU
+ * there is nothing truthful to tell the guest.
+ */
+ @NonNull
+ public static CpuPlacementPlan of(@NonNull DataItem item) {
+ var affinity = parseAffinity(item.optString(KEY_AFFINITY, ""));
+ if (affinity.isEmpty())
+ return new CpuPlacementPlan(affinity, new TreeMap<>(), new ArrayList<>());
+ if (item.optBoolean(KEY_AUTO, true)) {
+ var cap = deriveCapacity(affinity, CpuUtils.getCores());
+ int vcpuCount = (int) Math.max(item.optLong("cpu_count", 1), 1);
+ return new CpuPlacementPlan(affinity, cap, deriveClusters(cap, vcpuCount));
+ }
+ return new CpuPlacementPlan(
+ affinity,
+ parseCapacity(item.optString(KEY_CAPACITY, "")),
+ parseClusters(item.optString(KEY_CLUSTERS, ""))
+ );
+ }
+
+ /** Appends the crosvm flags for this plan; a no-op when no vCPU is pinned. */
+ public void appendArgs(@NonNull List
> parseClusters(@NonNull String spec) {
+ var out = new ArrayList
>();
+ for (var group : spec.split(CLUSTER_SEP)) {
+ var members = CpuUtils.parseCpuSet(group);
+ if (!members.isEmpty()) out.add(members);
+ }
+ return out;
+ }
+
+ @NonNull
+ public static String formatClusters(@NonNull List
> clusters) {
+ var sb = new StringBuilder();
+ for (var cluster : clusters) {
+ if (cluster.isEmpty()) continue;
+ if (sb.length() > 0) sb.append(CLUSTER_SEP);
+ sb.append(CpuUtils.compactRanges(joinCsv(cluster)));
+ }
+ return sb.toString();
+ }
+
+ /**
+ * The vCPUs sharing each capacity value, weakest capacity first. Both the
+ * cluster split and the UI's capacity summary are views of this grouping.
+ */
+ @NonNull
+ public static NavigableMap
> deriveClusters(
+ @NonNull Map
> clusters) {
+ var seen = new TreeSet
+ *
+ *
+ *