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 @@ + + + + + + @@ -16,6 +29,7 @@ + android:windowSoftInputMode="adjustNothing"> @@ -42,63 +56,63 @@ android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" android:exported="false" android:theme="@style/Theme.DroidVM.NoActionBar" - android:windowSoftInputMode="adjustResize" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing"> @@ -125,55 +139,69 @@ android:configChanges="orientation|screenSize|keyboardHidden|screenLayout|smallestScreenSize" android:exported="false" android:theme="@style/Theme.DroidVM.NoActionBar" - android:windowSoftInputMode="adjustResize" /> + android:windowSoftInputMode="adjustNothing" /> + + + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + + + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + android:windowSoftInputMode="adjustNothing" /> + + + android:windowSoftInputMode="adjustNothing" /> + + + diff --git a/app/src/main/aidl/cn/classfun/droidvm/display/INativeDisplayRootService.aidl b/app/src/main/aidl/cn/classfun/droidvm/display/INativeDisplayRootService.aidl index 16ec92ff..0ff74695 100644 --- a/app/src/main/aidl/cn/classfun/droidvm/display/INativeDisplayRootService.aidl +++ b/app/src/main/aidl/cn/classfun/droidvm/display/INativeDisplayRootService.aidl @@ -22,6 +22,13 @@ interface INativeDisplayRootService { * sidestepping both the SELinux 'connectto' denial (app->su socket) and the 'fd use' denial (app * receiving an su-owned fd). Returns true if written, false on any failure so the caller can fall * back to the vm_input IPC path. + * + * [screenId] is the screen the console sending the bytes is showing. The absolute channels + * (multi-touch, tablet) have one device per screen, because their coordinates only mean + * anything under one output's geometry, so the screen is what picks the device; the keyboard + * and the relative pointer are VM-wide and ignore it. A screen whose absolute input is + * switched off has no such device, and this returns false rather than sending the events to + * some other screen. */ - boolean writeInput(String vmId, int channel, in byte[] data); + boolean writeInput(String vmId, String screenId, int channel, in byte[] data); } diff --git a/app/src/main/assets/descr/gh_hugepage_reserve.html b/app/src/main/assets/descr/gh_hugepage_reserve.html new file mode 100644 index 00000000..824a7191 --- /dev/null +++ b/app/src/main/assets/descr/gh_hugepage_reserve.html @@ -0,0 +1,211 @@ + + + + + + +GH-Hugepage-Reserve + + + + + +
+
+

Why it's needed

+

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.

+
+ + + 1 + + + + + + 2 + + + + + + + + + + + + + + + 3 + + + + + + + + + + + + + + +
+ The dashed frame is the same 2MB huge page in all three rows, and all three have the same amount of memory in use — only its layout differs. +
    +
  1. Fresh boot: what is in use sits packed at the left, so that stretch is whole. Blue: the VM gets its page.
  2. +
  3. Later: only part of it is still packed, the rest has crumbled into slivers spread across everything. Orange: no 2MB fits anywhere.
  4. +
  5. With the reserve: memory is just as broken up, but the reserve keeps that stretch whole. Green: the page was set aside before the crumbling started.
  6. +
+ in usefreereserve +
+
+
+
+

What it does

+

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.

+
+
+

Note

+

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。

+
+ + + 1 + + + + + + 2 + + + + + + + + + + + + + + + 3 + + + + + + + + + + + + + + +
+ 虚线框在三行都是同一个 2MB 大页,三行的已使用总量也一样多,差别只在分布。 +
    +
  1. 刚开机:已使用的都挤在左边,这一段是完整的。蓝色:VM 要得到。
  2. +
  3. 用久了:只剩一部分还集中,其余碎成细条散落各处。橙色:哪里都塞不下 2MB。
  4. +
  5. 有保留池:内存一样零碎,但保留池把这一段整块留住。绿色:这个大页在碎掉之前就先留下来了。
  6. +
+ 已使用空闲保留池 +
+
+
+
+

功能

+

趁刚开机、内存还完整的时候,先抢下一批 2MB 大页并保留起来(数量可在管理页调整)。VM 开机时直接从保留池供应;VM 关机归还后,页面回收回池中,重复使用。

+
+
+

备注

+

这个模块必须在开机很早期就加载(否则内存已经碎了),所以以 Magisk / KernelSU / APatch 模块的形式另外安装,而不是由本 App 加载。

+
+
+ +
+
+

原因

+

Gunyah VM 的記憶體必須以 2MB 的大頁來供應,每一個都是一整塊連續的記憶體。但系統平常是以 4KB 的小頁在配置記憶體,而且其中不少一旦放下就不能再搬動。

+

開機越久,這些小頁越是四處散落,能湊出完整 2MB 的位置就越來越少;到 VM 要開機時,往往已經湊不齊足夠的大頁,只能失敗並顯示 Out of memory。

+
+ + + 1 + + + + + + 2 + + + + + + + + + + + + + + + 3 + + + + + + + + + + + + + + +
+ 虛線框在三行都是同一個 2MB 大頁,三行的已使用總量也一樣多,差別只在分布。 +
    +
  1. 剛開機:已使用的都擠在左邊,這一段是完整的。藍色:VM 要得到。
  2. +
  3. 用久了:只剩一部分還集中,其餘碎成細條散落各處。橘色:哪裡都塞不下 2MB。
  4. +
  5. 有保留池:記憶體一樣零碎,但保留池把這一段整塊留住。綠色:這個大頁在碎掉之前就先留下來了。
  6. +
+ 已使用空閒保留池 +
+
+
+
+

功能

+

趁剛開機、記憶體還完整的時候,先搶下一批 2MB 大頁並保留起來(數量可在管理頁調整)。VM 開機時直接從保留池供應;VM 關機歸還後,頁面回收回池中,重複使用。

+
+
+

備註

+

這個模組必須在開機很早期就載入(否則記憶體已經碎了),所以以 Magisk / KernelSU / APatch 模組的形式另外安裝,而不是由本 App 載入。

+
+
+ + + diff --git a/app/src/main/assets/descr/style.css b/app/src/main/assets/descr/style.css new file mode 100644 index 00000000..7e0bfe9b --- /dev/null +++ b/app/src/main/assets/descr/style.css @@ -0,0 +1,326 @@ +/* + * Shared stylesheet for the module description pages. + * + * THIS FILE IS THE AUTHORITY. 6_build_apk_prepare.sh copies it into the app at + * DroidVM/app/src/main/assets/descr/style.css, which is what actually renders on + * device (the app injects it inline; nothing is fetched at runtime). The copy is + * checked in so the app still builds without this repo -- edit it here, not there. + * + * The app injects, ahead of this file: + * :root { --surface, --on-surface, --on-surface-variant, --primary, --error, + * --outline, --surface-variant } <- the live Material theme, so pages + * follow dark mode and Material You + * html[data-theme="dark"|"light"] + * .i18n display rules selecting one language + * + * Opening a page straight in a browser therefore shows ALL languages stacked and + * the fallback palette below -- which is what you want when proofreading. + */ + +:root { + --surface: #fdfcff; + --on-surface: #1a1c1e; + --on-surface-variant: #43474e; + --surface-variant: #e0e2ec; + --primary: #345ca8; + --error: #ba1a1a; + --outline: #74777f; +} + +/* + * Traffic-light colours for what a request met: available / unavailable / held. + * Deliberately NOT taken from the app's palette -- under Material You --primary + * can be any hue, and "blue means you get it, orange means you don't" has to + * survive that. Only the greys and surfaces follow the theme. + */ +:root { + --fit: #1565c0; + --nofit: #b45309; + --held: #2e7d32; +} + +/* Browser-preview only: on device the app states the theme outright, below. */ +@media (prefers-color-scheme: dark) { + :root { + --surface: #1a1c1e; + --on-surface: #e2e2e6; + --on-surface-variant: #c3c6cf; + --surface-variant: #43474e; + --primary: #adc6ff; + --error: #ffb4ab; + --outline: #8d9199; + --fit: #9fc5ff; + --nofit: #ffb877; + --held: #7edb96; + } +} + +/* + * The app stamps data-theme on , and that is the authority: a WebView's own + * idea of dark need not match the app's. Both directions are spelled out because + * either has to beat the media query above. Theme colours are absent here on + * purpose -- the app injects those as :root variables, and an attribute selector + * would outrank them and undo Material You. + */ +html[data-theme="dark"] { + --fit: #9fc5ff; + --nofit: #ffb877; + --held: #7edb96; +} + +html[data-theme="light"] { + --fit: #1565c0; + --nofit: #b45309; + --held: #2e7d32; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0 2px 8px; + background: var(--surface); + color: var(--on-surface); + font-family: system-ui, sans-serif; + font-size: 15px; + line-height: 1.65; + /* The dialog is narrow; never let a long token push a sideways scrollbar. */ + overflow-wrap: anywhere; +} + +h2 { + margin: 18px 0 6px; + font-size: 15px; + font-weight: 600; + color: var(--primary); +} + +section:first-of-type h2 { + margin-top: 2px; +} + +p { + margin: 0 0 10px; +} + +code { + font-family: ui-monospace, monospace; + font-size: 0.92em; + padding: 1px 4px; + border-radius: 4px; + background: var(--surface-variant); +} + +/* Anything the text enumerates is a list, not a run-on sentence with numbers in it. + In a caption the list is what maps the drawing's rows to words, so it inherits the + caption's smaller type rather than resetting it. */ +ol, ul { + margin: 0 0 10px; + padding-left: 1.4em; +} + +li { + margin-bottom: 5px; +} + +li:last-child { + margin-bottom: 0; +} + +/* ---- figures ---------------------------------------------------------- */ + +/* Figures close a "why" section: the words first, then the picture that settles them. */ +figure { + margin: 14px 0 4px; + padding: 12px; + border: 1px solid var(--outline); + border-radius: 12px; +} + +figure svg { + display: block; + width: 100%; + height: auto; +} + +figcaption { + margin-top: 8px; + font-size: 13px; + line-height: 1.5; + color: var(--on-surface-variant); +} + +/* SVG text and shape roles. Diagrams are drawn in theme colours only -- no + hardcoded ink -- so they stay legible when the app flips to dark. */ +svg .lbl { + font: 500 9px system-ui, sans-serif; + fill: var(--on-surface-variant); +} + +svg .lbl-strong { + font: 600 9.5px system-ui, sans-serif; + fill: var(--on-surface); +} + +svg .used { + fill: var(--on-surface-variant); +} + +svg .free { + fill: var(--surface-variant); +} + +svg .keep { + fill: var(--primary); +} + +svg .bad { + fill: var(--error); +} + +svg .edge { + fill: none; + stroke: var(--outline); + stroke-width: 1; +} + +svg .edge-strong { + fill: none; + stroke: var(--primary); + stroke-width: 1.5; +} + +/* A request that cannot be satisfied, drawn over the memory it would need. */ +svg .miss { + fill: none; + stroke: var(--nofit); + stroke-width: 1.5; + stroke-dasharray: 4 3; +} + +/* A request already covered by memory set aside for it, and that memory. */ +svg .hold { + fill: none; + stroke: var(--held); + stroke-width: 1.5; + stroke-dasharray: 4 3; +} + +svg .pool { + fill: var(--held); + fill-opacity: 0.3; +} + +/* A request that can be satisfied, drawn over the memory it will use. */ +svg .want { + fill: none; + stroke: var(--fit); + stroke-width: 1.5; + stroke-dasharray: 4 3; +} + +svg .dash { + fill: none; + stroke: var(--outline); + stroke-width: 1.2; + stroke-dasharray: 4 3; +} + +svg .x-mark { + fill: none; + stroke: var(--nofit); + stroke-width: 2; + stroke-linecap: round; +} + +svg .ok-mark { + fill: none; + stroke: var(--fit); + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +svg .arrow { + fill: var(--primary); +} + +svg .box { + fill: var(--surface-variant); +} + +/* ---- three-mode table (udmabuf) --------------------------------------- */ + +table { + width: 100%; + margin: 10px 0; + border-collapse: collapse; + font-size: 13.5px; +} + +th, td { + padding: 6px 8px; + text-align: left; + vertical-align: top; + border-bottom: 1px solid var(--outline); +} + +th { + font-weight: 600; + color: var(--on-surface-variant); +} + +tr:last-child td { + border-bottom: none; +} + +/* ---- language sections ------------------------------------------------ */ + +.i18n + .i18n { + margin-top: 28px; + padding-top: 20px; + border-top: 2px dashed var(--outline); +} + +/* What the shades in a figure mean. Swatches are the same variables the SVG uses, + so a legend cannot drift from the drawing it explains. */ +.legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin-top: 8px; + font-size: 12.5px; +} + +.legend span { + white-space: nowrap; +} + +.legend i { + display: inline-block; + width: 12px; + height: 12px; + margin-right: 5px; + border-radius: 3px; + vertical-align: -1px; +} + +.legend .sw-used { + background: var(--on-surface-variant); +} + +.legend .sw-free { + background: var(--surface-variant); +} + +.legend .sw-keep { + background: var(--primary); +} + +.legend .sw-pool { + background: var(--held); + opacity: 0.5; +} + diff --git a/app/src/main/assets/match.json b/app/src/main/assets/match.json new file mode 100644 index 00000000..cc28fa02 --- /dev/null +++ b/app/src/main/assets/match.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "modules": { + "gh_hugepage_reserve": { "soc_vendor": ["qualcomm"] }, + "gunyah_host_share": { "soc_vendor": ["qualcomm"] }, + "gunyah_kvcalloc": { "soc_vendor": ["qualcomm"] }, + "gh_unmovable": { "soc_vendor": ["qualcomm"] }, + "udmabuf": { "comment": "generic dma-buf provider/repair, nothing Gunyah about it: any SoC" }, + "nproc_guard": { "comment": "no-reboot rescue for the per-uid RLIMIT_NPROC ucounts desync that wedges the app; loaded by the daemon with uid=. Any SoC." } + }, + "names": { + "gunyah_host_share": "Gunyah Host Share", + "gunyah_kvcalloc": "Gunyah kvcalloc Fix", + "gh_unmovable": "GH Unmovable", + "udmabuf": "Udmabuf Fix", + "nproc_guard": "NPROC Guard" + } +} diff --git a/app/src/main/assets/prebuilts b/app/src/main/assets/prebuilts index 2c31cecd..5cc4aa89 160000 --- a/app/src/main/assets/prebuilts +++ b/app/src/main/assets/prebuilts @@ -1 +1 @@ -Subproject commit 2c31cecdedb5110e8861b8b698ba5b538725cfb7 +Subproject commit 5cc4aa892c8cd25b77b386e787442ce6975cfa31 diff --git a/app/src/main/cpp/CMakeLists.txt b/app/src/main/cpp/CMakeLists.txt index d7a0eca9..709e377d 100644 --- a/app/src/main/cpp/CMakeLists.txt +++ b/app/src/main/cpp/CMakeLists.txt @@ -121,6 +121,13 @@ add_library(unixhelper SHARED unixhelper/unixhelper.c unixhelper/native_process. target_link_libraries(unixhelper log) target_compile_options(unixhelper PRIVATE -Wall -Wextra -O2) +# Capability probe for the native-display GPU-blit provider (GpuBlitProvider.SYSTEM). Loads the +# platform Vulkan loader at runtime (dlopen, not linked) and reports which blit extensions the +# stock driver lacks, so the UI can warn before the bridge silently degrades to a CPU copy. +add_library(vkprobe SHARED vkprobe/vkprobe.cpp) +target_link_libraries(vkprobe log) +target_compile_options(vkprobe PRIVATE -Wall -Wextra -O2) + add_executable(daemon unixhelper/daemon.c) target_compile_options(daemon PRIVATE -Wall -Wextra -O2) diff --git a/app/src/main/cpp/compat/a14.S b/app/src/main/cpp/compat/a14.S index b3f50afb..c7a06eac 100644 --- a/app/src/main/cpp/compat/a14.S +++ b/app/src/main/cpp/compat/a14.S @@ -25,4 +25,14 @@ _ZNSt3__113basic_filebufIcNS_11char_traitsIcEEEC1Ev: .type _ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE5closeEv, %function _ZNSt3__113basic_filebufIcNS_11char_traitsIcEEE5closeEv: b _ZNSt6__ndk113basic_filebufIcNS_11char_traitsIcEEE5closeEv + +// std::__1::__fs::filesystem::path::__filename() const +// a14's platform libc++ predates the out-of-line path parsers; libgfxstream_backend.so +// (built against the a16 soong libc++) is the one importer in the payload. path is +// {basic_string} and the return is a string_view in both namespaces, so the __ndk1 +// implementation baked in by c++_static is layout-compatible. +.globl _ZNKSt3__14__fs10filesystem4path10__filenameEv +.type _ZNKSt3__14__fs10filesystem4path10__filenameEv, %function +_ZNKSt3__14__fs10filesystem4path10__filenameEv: + b _ZNKSt6__ndk14__fs10filesystem4path10__filenameEv #endif diff --git a/app/src/main/cpp/vkprobe/vkprobe.cpp b/app/src/main/cpp/vkprobe/vkprobe.cpp new file mode 100644 index 00000000..b63b93f9 --- /dev/null +++ b/app/src/main/cpp/vkprobe/vkprobe.cpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright DroidVM contributors +// Additional permissions apply; see ADDITIONAL-PERMISSIONS in the repository root. +// +// Capability probe for the native-display GPU-blit provider (GpuBlitProvider.SYSTEM). +// +// The crosvm display bridge (crosvm_android_display_client.cpp) imports the virtio-gpu +// scanout dmabuf as a VkImage and blits it into the SurfaceControl buffer. To do so it +// enables a fixed set of device extensions at vkCreateDevice; a driver that does not expose +// them cannot run the blit and the bridge silently degrades to a CPU copy. This probe lets +// the UI tell the user *before* they pick SYSTEM which of those extensions their platform's +// stock Vulkan driver lacks. +// +// It is deliberately general: it enumerates the actual driver's extension list and compares +// against the bridge's requirements, with no per-vendor ("Qualcomm fails") special-casing. +// The list below must stay in sync with the bridge's device-creation extension set. + +#include +#include +#include + +#include +#include +#include + +#define VK_NO_PROTOTYPES +#include + +#define LOG_TAG "vkprobe" +#define LOGW(...) __android_log_print(ANDROID_LOG_WARN, LOG_TAG, __VA_ARGS__) + +namespace { + +// Device extensions crosvm_android_display_client.cpp enables at vkCreateDevice. Keep in sync. +const char* const kRequired[] = { + "VK_EXT_external_memory_dma_buf", + "VK_EXT_image_drm_format_modifier", + "VK_ANDROID_external_memory_android_hardware_buffer", + "VK_KHR_external_memory_fd", + "VK_EXT_queue_family_foreign", + "VK_KHR_external_semaphore", + "VK_KHR_external_semaphore_fd", +}; + +std::vector deviceMissing(PFN_vkEnumerateDeviceExtensionProperties enumExt, + VkPhysicalDevice dev) { + uint32_t n = 0; + enumExt(dev, nullptr, &n, nullptr); + std::vector props(n); + if (n) enumExt(dev, nullptr, &n, props.data()); + std::vector missing; + for (const char* req : kRequired) { + bool found = false; + for (const auto& p : props) + if (std::strcmp(p.extensionName, req) == 0) { found = true; break; } + if (!found) missing.emplace_back(req); + } + return missing; +} + +} // namespace + +// Returns a String[] of the required extensions the most-capable physical device is missing +// (empty => some device supports all of them => SYSTEM blit is usable), or null if the probe +// could not run at all (no loader / no instance / no device) so the caller can treat it as +// "unknown" rather than "incapable". +extern "C" JNIEXPORT jobjectArray JNICALL +Java_cn_classfun_droidvm_lib_natives_VulkanBlitProbe_nativeMissingBlitExtensions(JNIEnv* env, + jclass) { + void* lib = dlopen("libvulkan.so", RTLD_NOW | RTLD_LOCAL); + if (!lib) { LOGW("dlopen libvulkan.so: %s", dlerror()); return nullptr; } + + auto gipa = reinterpret_cast(dlsym(lib, "vkGetInstanceProcAddr")); + if (!gipa) { dlclose(lib); return nullptr; } + auto createInstance = + reinterpret_cast(gipa(VK_NULL_HANDLE, "vkCreateInstance")); + if (!createInstance) { dlclose(lib); return nullptr; } + + VkApplicationInfo appInfo{}; + appInfo.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; + appInfo.pApplicationName = "droidvm-vkprobe"; + appInfo.apiVersion = VK_API_VERSION_1_0; // widest acceptance; ext query is version-agnostic + VkInstanceCreateInfo ici{}; + ici.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; + ici.pApplicationInfo = &appInfo; + + VkInstance inst = VK_NULL_HANDLE; + if (createInstance(&ici, nullptr, &inst) != VK_SUCCESS || inst == VK_NULL_HANDLE) { + dlclose(lib); + return nullptr; + } + + auto enumPhys = + reinterpret_cast(gipa(inst, "vkEnumeratePhysicalDevices")); + auto enumExt = reinterpret_cast( + gipa(inst, "vkEnumerateDeviceExtensionProperties")); + auto destroyInstance = + reinterpret_cast(gipa(inst, "vkDestroyInstance")); + + std::vector best; + bool haveBest = false; + if (enumPhys && enumExt) { + uint32_t nDev = 0; + enumPhys(inst, &nDev, nullptr); + std::vector devs(nDev); + if (nDev) enumPhys(inst, &nDev, devs.data()); + for (VkPhysicalDevice dev : devs) { + std::vector miss = deviceMissing(enumExt, dev); + if (miss.empty()) { best.clear(); haveBest = true; break; } + if (!haveBest || miss.size() < best.size()) { best = std::move(miss); haveBest = true; } + } + } + + if (destroyInstance) destroyInstance(inst, nullptr); + dlclose(lib); + + if (!haveBest) return nullptr; // no physical device answered -> unknown + + jclass strCls = env->FindClass("java/lang/String"); + jobjectArray out = env->NewObjectArray(static_cast(best.size()), strCls, nullptr); + for (jsize i = 0; i < static_cast(best.size()); i++) { + jstring s = env->NewStringUTF(best[i].c_str()); + env->SetObjectArrayElement(out, i, s); + env->DeleteLocalRef(s); + } + return out; +} diff --git a/app/src/main/cpp/vnc/vncclient_jni.c b/app/src/main/cpp/vnc/vncclient_jni.c index f2f06fb0..61b2bd3e 100644 --- a/app/src/main/cpp/vnc/vncclient_jni.c +++ b/app/src/main/cpp/vnc/vncclient_jni.c @@ -13,6 +13,24 @@ #define LOGW(...) __android_log_print(ANDROID_LOG_WARN, TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, TAG, __VA_ARGS__) +/* THE TWO ENCODINGS THAT CARRY THE HARDWARE H.264 STREAM, on the ordinary RFB port. + * + * 50 is rfbproto.rst's "Open H.264". 0x44564831 is "DVH1", DroidVM's private pseudo-encoding, + * whose fixed four-byte payload says the two things RFB negotiation cannot: whether the host has + * an encoder at all, and -- on an idle screen, where a live stream and a dead one look alike -- + * that the connection is still there. Both numbers and the payload layout are pinned by + * plans/H264_SINGLE_PORT.md section 1 and are implemented here as written, not renegotiated. */ +#define VNC_ENCODING_H264 50 +#define VNC_ENCODING_DVH1 0x44564831 +/* An encoding-50 rect body opens with u32 BE length and u32 BE flags, then the Annex-B payload. */ +#define H264_RECT_HEADER_BYTES 8 +/* The whole of a DVH1 rect: version, kind, value, reserved. */ +#define DVH1_PAYLOAD_BYTES 4 +/* Above this the length prefix describes a stream that has lost its place rather than a frame, and + * the only thing left to do with the connection is end it. Mirrors the Java parser's own guard -- + * both sides check because both sides allocate. */ +#define H264_RECT_MAX_PAYLOAD (16 * 1024 * 1024) + typedef struct { JavaVM *jvm; jobject callback; @@ -23,12 +41,163 @@ typedef struct { volatile int stop_requested; pthread_mutex_t lock; char *password; + /* Set by the rect handlers below and read-and-cleared by vnc_update. Neither of our rects + * touches the framebuffer, but libvncclient calls GotFrameBufferUpdate for every rect it + * dispatched -- and the Java side answers that by copying the whole framebuffer into a Bitmap. + * Without this the console would do a full-screen copy once per decoded frame, for pixels + * nothing is going to look at. */ + int rect_was_ours; } VncContext; static VncContext *ctx_of(rfbClient *cl) { return (VncContext *) rfbClientGetClientData(cl, (void *) 0xDEAD); } +static uint32_t be32(const uint8_t *p) { + return ((uint32_t) p[0] << 24) | ((uint32_t) p[1] << 16) + | ((uint32_t) p[2] << 8) | (uint32_t) p[3]; +} + +/** + * A JNIEnv for whichever thread is calling, and whether it had to be attached to get one. + * + * In practice every caller is the console's own message-loop thread, which is a Java thread and so + * already attached; the attach path is here because a JNIEnv borrowed from the wrong thread is + * undefined behaviour rather than an error, and that is not a thing to leave to a comment. + */ +static JNIEnv *env_for(VncContext *ctx, int *attached) { + JNIEnv *env = NULL; + *attached = 0; + if (!ctx->jvm || !ctx->callback) return NULL; + if ((*ctx->jvm)->GetEnv(ctx->jvm, (void **) &env, JNI_VERSION_1_6) != JNI_OK) { + if ((*ctx->jvm)->AttachCurrentThread(ctx->jvm, &env, NULL) != JNI_OK) return NULL; + *attached = 1; + } + return env; +} + +/** Hands one rect's bytes to a Java callback taking (byte[], int, int). */ +static void deliver_rect(VncContext *ctx, const char *method, const char *sig, + const uint8_t *data, size_t len, jint a, jint b, int with_size) { + int attached = 0; + JNIEnv *env = env_for(ctx, &attached); + if (!env) return; + jbyteArray arr = (*env)->NewByteArray(env, (jsize) len); + if (arr) { + (*env)->SetByteArrayRegion(env, arr, 0, (jsize) len, (const jbyte *) data); + jclass cls = (*env)->GetObjectClass(env, ctx->callback); + jmethodID mid = (*env)->GetMethodID(env, cls, method, sig); + if (mid) { + if (with_size) (*env)->CallVoidMethod(env, ctx->callback, mid, arr, a, b); + else (*env)->CallVoidMethod(env, ctx->callback, mid, arr); + } + (*env)->DeleteLocalRef(env, cls); + /* Deleted rather than left to the frame: a decoded second is sixty of these, and the + * native frame they would sit in is not unwound until nativeProcessMessages returns. */ + (*env)->DeleteLocalRef(env, arr); + } else { + /* The allocation failed and the exception is pending; clearing it here keeps the rect + * loop's own error reporting intact instead of tripping over ours on the next JNI call. */ + (*env)->ExceptionClear(env); + } + if (attached) (*ctx->jvm)->DetachCurrentThread(ctx->jvm); +} + +/** + * Reads an encoding-50 rect and hands the whole body -- the eight-byte header included -- to Java. + * + * The header is passed on rather than consumed here so that the length and the flags are parsed in + * exactly one place, by the Java class the unit tests can feed the seam's literal bytes to. The + * length is read twice, which is unavoidable: a length-prefixed message cannot be read off a socket + * without reading its prefix. What that costs is checked rather than assumed -- the Java parser + * refuses a body whose declared length disagrees with the bytes it was given. + */ +static rfbBool read_h264_rect(rfbClient *cl, VncContext *ctx, + rfbFramebufferUpdateRectHeader *rect) { + uint8_t head[H264_RECT_HEADER_BYTES]; + uint32_t length; + uint8_t *body; + + if (!ReadFromRFBServer(cl, (char *) head, H264_RECT_HEADER_BYTES)) return FALSE; + length = be32(head); + if (length > H264_RECT_MAX_PAYLOAD) { + LOGE("h264 rect claims %u bytes; the stream has lost its place", length); + return FALSE; + } + body = (uint8_t *) malloc(H264_RECT_HEADER_BYTES + (size_t) length); + if (!body) { + LOGE("out of memory for a %u-byte h264 rect", length); + return FALSE; + } + memcpy(body, head, H264_RECT_HEADER_BYTES); + if (length > 0 && + !ReadFromRFBServer(cl, (char *) (body + H264_RECT_HEADER_BYTES), length)) { + free(body); + return FALSE; + } + deliver_rect(ctx, "onH264Rect", "([BII)V", body, + H264_RECT_HEADER_BYTES + (size_t) length, + rect->r.w, rect->r.h, 1); + free(body); + return TRUE; +} + +/** + * The client half of the two encodings, called by libvncclient for any rect it does not know. + * + * Returning FALSE for a rect that IS ours (a short read, a length past the guard) is deliberate: + * libvncclient's rect loop treats an unhandled rect as a protocol failure and drops the + * connection, which is the only correct answer once the byte stream and the parser disagree about + * where the next rect begins. The reason is logged here first, because the loop's own message + * would blame the encoding number rather than the stream. + */ +static rfbBool handle_dvh_rect(rfbClient *cl, rfbFramebufferUpdateRectHeader *rect) { + VncContext *ctx = ctx_of(cl); + uint8_t payload[DVH1_PAYLOAD_BYTES]; + + if (!ctx) return FALSE; + if (rect->encoding == VNC_ENCODING_DVH1) { + if (!ReadFromRFBServer(cl, (char *) payload, DVH1_PAYLOAD_BYTES)) return FALSE; + ctx->rect_was_ours = 1; + deliver_rect(ctx, "onDvhRect", "([B)V", payload, DVH1_PAYLOAD_BYTES, 0, 0, 0); + return TRUE; + } + if (rect->encoding == VNC_ENCODING_H264) { + /* Set before the read, not after: a failure here ends the connection, and the framebuffer + * notification for a rect that was ours must not go out on the way down. */ + ctx->rect_was_ours = 1; + return read_h264_rect(cl, ctx, rect); + } + return FALSE; +} + +/* The encodings this client asks for, and the switch that stops it asking. + * + * libvncclient's extension list is process-global (rfbclient.c:105), so this array is read by + * every connection rather than by one. That is the right scope for the one thing that turns it + * off: a device with no video/avc decoder cannot decode this stream on any connection, and a + * console that stays enrolled on a stream it cannot decode is a console showing a frozen picture, + * because the server stops sending pixels to clients that asked for 50. Swapping the pointer + * rather than editing the array in place keeps the read side seeing one list or the other and + * never half of an edit. */ +static int dvh_encodings_on[] = {VNC_ENCODING_DVH1, VNC_ENCODING_H264, 0}; +static int dvh_encodings_off[] = {0}; + +static rfbClientProtocolExtension dvh_extension = { + .encodings = dvh_encodings_on, + .handleEncoding = handle_dvh_rect, + .handleMessage = NULL, + .next = NULL, + .securityTypes = NULL, + .handleAuthentication = NULL, +}; + +static pthread_once_t dvh_registered = PTHREAD_ONCE_INIT; + +static void register_dvh_extension(void) { + rfbClientRegisterExtension(&dvh_extension); +} + static rfbBool vnc_resize(rfbClient *cl) { VncContext *ctx = ctx_of(cl); int w = cl->width; @@ -71,6 +240,13 @@ static rfbBool vnc_resize(rfbClient *cl) { static void vnc_update(rfbClient *cl, int x, int y, int w, int h) { VncContext *ctx = ctx_of(cl); JNIEnv *env = NULL; + if (!ctx) return; + if (ctx->rect_was_ours) { + /* Cleared here rather than in the handler because this runs once per dispatched rect, + * which is the only place the flag can be retired without a second bookkeeping rule. */ + ctx->rect_was_ours = 0; + return; + } if (!ctx->jvm || !ctx->callback) return; int attached = 0; if ((*ctx->jvm)->GetEnv(ctx->jvm, (void **) &env, JNI_VERSION_1_6) != JNI_OK) { @@ -96,9 +272,21 @@ static char *vnc_get_password(rfbClient *cl) { #define JNI_PREFIX(name) \ Java_cn_classfun_droidvm_ui_vm_display_vnc_base_VncClient_##name +JNIEXPORT void JNICALL +JNI_PREFIX(nativeSetH264Advertised)(JNIEnv *env, jclass cls, jboolean advertised) { + (void) env; + (void) cls; + dvh_extension.encodings = advertised ? dvh_encodings_on : dvh_encodings_off; + LOGI("H.264 encodings %s for connections made from now on", + advertised ? "advertised" : "withdrawn"); +} + JNIEXPORT jlong JNICALL JNI_PREFIX(nativeCreate)(JNIEnv *env, jobject thiz) { (void) thiz; + /* Once per process: the list this joins is global, and registering twice would put both + * encoding numbers on the wire twice and run every rect through two identical handlers. */ + pthread_once(&dvh_registered, register_dvh_extension); rfbClient *cl = rfbGetClient(8, 3, 4); if (!cl) { LOGE("rfbGetClient failed"); diff --git a/app/src/main/java/cn/classfun/droidvm/DroidVMApp.java b/app/src/main/java/cn/classfun/droidvm/DroidVMApp.java index 4d6a266e..c913ca39 100644 --- a/app/src/main/java/cn/classfun/droidvm/DroidVMApp.java +++ b/app/src/main/java/cn/classfun/droidvm/DroidVMApp.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; import android.app.Application; @@ -13,6 +16,7 @@ import cn.classfun.droidvm.lib.store.vm.VMStore; import cn.classfun.droidvm.lib.ui.ImeInsetsApplier; import cn.classfun.droidvm.lib.utils.ThreadUtils; +import cn.classfun.droidvm.ui.main.settings.KernelModuleManager; public final class DroidVMApp extends Application { private static final String TAG = "DroidVMApp"; @@ -30,6 +34,11 @@ public void onCreate() { initializeStore(new VMStore()); initializeStore(new DiskStore()); initializeStore(new NetworkStore()); + try { + KernelModuleManager.applyAutostart(this); + } catch (Exception e) { + Log.w(TAG, "kernel module autostart failed", e); + } }); } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/Daemon.java b/app/src/main/java/cn/classfun/droidvm/daemon/Daemon.java index 6db9c122..65527212 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/Daemon.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/Daemon.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; import static android.os.Process.myPid; @@ -29,6 +32,7 @@ import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import cn.classfun.droidvm.daemon.audio.HostAudioTable; import cn.classfun.droidvm.daemon.display.DaemonSystemContext; import cn.classfun.droidvm.daemon.server.Server; import cn.classfun.droidvm.lib.natives.UnixHelper; @@ -269,6 +273,10 @@ public static void main(String... args) { System.out.print("Another DroidVM Daemon is already running.\n"); System.exit(1); } + // Publish the host's audio endpoints before any VM starts, and keep them current: a VM + // pinned to a headset needs to find it again by name after it is reconnected, and the + // number it had before will not be the number it has after. + HostAudioTable.start(DaemonSystemContext.get()); daemonHash = getMyHash(); Log.i(TAG, fmt("DroidVM Daemon hash: %s", daemonHash)); writePidFile(); diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/audio/HostAudioTable.java b/app/src/main/java/cn/classfun/droidvm/daemon/audio/HostAudioTable.java new file mode 100644 index 00000000..32d9ba34 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/audio/HostAudioTable.java @@ -0,0 +1,176 @@ +// 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.audio; + +import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; + +import android.content.Context; +import android.media.AudioDeviceCallback; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.os.Handler; +import android.os.HandlerThread; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import cn.classfun.droidvm.lib.data.HostAudioDevices; + +/** + * Publishes the host's audio endpoints where crosvm's audio backend can read them. + * + *

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 \t\t}. Written to a neighbouring file and + * renamed, so a reader never sees half a table -- half a table resolves an endpoint to + * nothing, and the backend would fall back to the platform's routing for no reason.

+ */ + /** + * Rewrites the table, and never lets a failure escape. + * + *

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.ArrayList(); + var ids = HostAudioDevices.idsAndKeys(context, input, keys); + for (int i = 0; i < ids.size() && i < keys.size(); i++) { + var row = ids.get(i); + // id, direction, name, then what the endpoint itself is: native rate, channel count, + // and kind. A reader that only wants the id stops after the third column. + text.append(fmt("%d\t%s\t%s\t%d\t%d\t%d\n", row[0], input ? "in" : "out", + keys.get(i), row[1], row[2], row[3])); + } + return keys.size(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/console/ConsoleStream.java b/app/src/main/java/cn/classfun/droidvm/daemon/console/ConsoleStream.java index 023fbe25..d5ab7307 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/console/ConsoleStream.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/console/ConsoleStream.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 static java.nio.charset.StandardCharsets.UTF_8; @@ -37,6 +40,7 @@ public abstract class ConsoleStream implements Closeable, JSONSerialize { private Thread readerThread; private OutputStream logWriter = null; private boolean disableSave = false; + private boolean persistentLogEnabled = true; public ConsoleStream(@NonNull VMConfig config, @NonNull String name) { this.config = config; @@ -83,7 +87,7 @@ public void appendBuffer(@NonNull byte[] data) { public void appendBuffer(@NonNull byte[] data, int off, int len) { buffer.adds(data, off, len); - if (disableSave) return; + if (disableSave || !persistentLogEnabled) return; try { if (logWriter == null) { var path = getPersistentPath(); @@ -107,6 +111,25 @@ public void appendBuffer(@NonNull byte[] data, int off, int len) { } } + /** + * Controls whether future output is copied to the persistent console log. + * + *

Agent 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 @@ *
  • {@link INativeDisplayRootService#waitForDisplayBinder(String)} - look up the per-VM * ICrosvmAndroidDisplayService binder crosvm registers via * {@code --android-display-service } (an untrusted_app can't do this lookup).
  • - *
  • {@link INativeDisplayRootService#writeInput(String, int, byte[])} - write evdev straight to - * the crosvm input socket the daemon owns (no extra socket hop), by looking up the VM.
  • + *
  • {@link INativeDisplayRootService#writeInput(String, String, int, byte[])} - write evdev + * straight to the crosvm input socket the daemon owns (no extra socket hop), by looking up + * the VM and the screen the console sending it is showing.
  • * * * The binder can't ride the daemon's TCP/JSON-RPC channel, so it is broadcast to the UI through @@ -33,6 +38,10 @@ public final class NativeDisplayBinder { private static final String TAG = "NativeDisplayBinder"; + /** How long one lookup keeps looking, and how often it looks. */ + private static final long WAIT_TOTAL_MS = 5000; + private static final long WAIT_POLL_MS = 200; + private static INativeDisplayRootService.Stub binder; private NativeDisplayBinder() { @@ -51,15 +60,15 @@ private static INativeDisplayRootService.Stub createBinder(@NonNull ServerContex return new INativeDisplayRootService.Stub() { @Override public IBinder waitForDisplayBinder(String serviceName) { - return doWaitForDisplayBinder(serviceName); + return doWaitForDisplayBinder(ctx, serviceName); } @Override - public boolean writeInput(String vmId, int channel, byte[] data) { + public boolean writeInput(String vmId, String screenId, int channel, byte[] data) { if (vmId == null || data == null || data.length == 0) return false; var inst = ctx.getVMs().findById(vmId); if (inst == null) return false; - return inst.writeNativeInput(channel, data); + return inst.writeNativeInput(screenId == null ? "" : screenId, channel, data); } }; } @@ -93,32 +102,68 @@ private static IBinder smCall(@NonNull String method, @NonNull String name) { } } - private static IBinder waitForServiceWithTimeout(@NonNull String name, long timeoutMs) { - var holder = new IBinder[1]; - var t = new Thread(() -> holder[0] = smCall("waitForService", name), fmt("WaitSvc-%s", name)); - t.setDaemon(true); - t.start(); - try { - t.join(timeoutMs); - } catch (InterruptedException ignored) { + /** + * Look for [name] until it turns up or [WAIT_TOTAL_MS] passes. + * + * Polls {@code checkService} rather than calling {@code waitForService}, which blocks until + * the service appears and cannot be cancelled. A caller-side timeout around it does not stop + * it: the thread carrying it stays blocked for the life of the process, and while it waits, + * servicemanager's client logs a line a second, from every thread. For a VM that has stopped + * -- the display console left open after the VM exits, retrying -- the service never appears + * at all, so every attempt leaked another permanently-waiting, permanently-logging thread. + * Measured: 315884 log lines and 77 MB of daemon.log in four minutes, ending with the daemon + * dying and taking a different, running VM's crosvm down with it. + */ + private static IBinder pollForService(@NonNull String name) { + long deadline = System.nanoTime() + WAIT_TOTAL_MS * 1_000_000L; + while (System.nanoTime() < deadline) { + try { + Thread.sleep(WAIT_POLL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + var binder = smCall("checkService", name); + if (binder != null) return binder; } - return holder[0]; + return null; } - private static IBinder doWaitForDisplayBinder(@NonNull String serviceName) { - Log.i(TAG, fmt("waitForDisplayBinder('%s')", serviceName)); + /** + * Whether the VM behind [serviceName] is in a state where crosvm could still register it. + * + * A stopped VM will never register, so waiting for it is time the caller spends learning + * nothing -- and the caller is a console that retries. Unknown names are not ours to judge, + * so they wait as before. + */ + private static boolean vmCouldRegister(@NonNull ServerContext ctx, @NonNull String serviceName) { + // NativeDisplay owns both halves of the name -- the VM's channel root and the screen id + // appended to it -- so the reverse mapping lives there too rather than as a prefix strip + // written out again here, which is how it would silently stop matching. + var vmId = NativeDisplay.vmIdFromServiceName(serviceName); + if (vmId.isEmpty()) return true; + var inst = ctx.getVMs().findById(vmId); + if (inst == null) return true; + var state = inst.getState(); + return state == VMState.RUNNING || state == VMState.STARTING || state == VMState.REBOOTING; + } + + private static IBinder doWaitForDisplayBinder(@NonNull ServerContext ctx, + @NonNull String serviceName) { var direct = smCall("checkService", serviceName); - if (direct != null) { - Log.i(TAG, "OK: got display binder directly from ServiceManager"); - return direct; + if (direct != null) return direct; + if (!vmCouldRegister(ctx, serviceName)) { + Log.i(TAG, fmt("'%s': VM not running, nothing to wait for", serviceName)); + return null; } - Log.i(TAG, "Not found, waiting up to 5s..."); - var waited = waitForServiceWithTimeout(serviceName, 5000L); + Log.i(TAG, fmt("waitForDisplayBinder('%s'): not registered yet, looking for %d ms", + serviceName, WAIT_TOTAL_MS)); + var waited = pollForService(serviceName); if (waited != null) { - Log.i(TAG, "OK: got display binder via waitForService"); + Log.i(TAG, "OK: got display binder"); return waited; } - Log.e(TAG, fmt("'%s' not found - is crosvm running with " + Log.w(TAG, fmt("'%s' not found - is crosvm running with " + "--android-display-service %s?", serviceName, serviceName)); return null; } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/AuthHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/AuthHandler.java index c918cbf4..400d8bb9 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/AuthHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/AuthHandler.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.basic; import static cn.classfun.droidvm.lib.daemon.DaemonHelper.getTokenFile; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/PingHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/PingHandler.java index 989d4377..3e976180 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/PingHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/PingHandler.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.basic; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/SetAppConfig.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/SetAppConfig.java index 33db76b3..ace7d77e 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/SetAppConfig.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/SetAppConfig.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.basic; import androidx.annotation.NonNull; @@ -6,6 +9,7 @@ import cn.classfun.droidvm.daemon.server.ClientRequest; import cn.classfun.droidvm.daemon.server.RequestHandler; +import cn.classfun.droidvm.daemon.vm.UsbAcmPool; import cn.classfun.droidvm.lib.store.base.DataItem; @AutoService(RequestHandler.class) @@ -25,5 +29,8 @@ public void handle(@NonNull ClientRequest request) throws Exception { var data = DataItem.fromJson(cfg); var ctx = request.getContext(); ctx.appConfig = data; + // The ACM pool is standing daemon state driven by this config: build or tear down + // right away so toggling the setting acts without waiting for a VM start. + UsbAcmPool.applyConfig(data); } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/VersionHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/VersionHandler.java index 00733553..b5d64b90 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/VersionHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/basic/VersionHandler.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.basic; import static cn.classfun.droidvm.daemon.Daemon.daemonHash; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddAddressHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddAddressHandler.java index 2fd9bb10..23161249 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddAddressHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddAddressHandler.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/AddInterfaceHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddInterfaceHandler.java index e2df64f3..640a1097 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddInterfaceHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/AddInterfaceHandler.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/CreateHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/CreateHandler.java index e1ac41fc..924f7731 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/CreateHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/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.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/DeleteHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/DeleteHandler.java index 37b3e465..adb641a5 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/DeleteHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/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.network; import static java.util.UUID.fromString; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ExistsHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ExistsHandler.java index 5278a111..52e54d06 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ExistsHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/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.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/InfoHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/InfoHandler.java index 33e19494..2db683ed 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/InfoHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/InfoHandler.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/ListHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHandler.java index aeefda1a..262a314d 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/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.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHostAddressesHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHostAddressesHandler.java new file mode 100644 index 00000000..b056e965 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/ipc/network/ListHostAddressesHandler.java @@ -0,0 +1,34 @@ +// 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; + +import com.google.auto.service.AutoService; + +import cn.classfun.droidvm.daemon.network.backend.HostAddressScan; +import cn.classfun.droidvm.daemon.server.ClientRequest; +import cn.classfun.droidvm.daemon.server.RequestHandler; + +/** + * The phone's own addresses, for a UI offering somewhere to listen. + * + *

    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 List HOST_IFACE_PREFIXES = List.of( - "wlan", "rmnet_data", "tun", "eth", - "ap", "swlan", "softap", "rndis", "usb", "bt-pan" - ); + private static final List HOST_IFACE_PREFIXES = + HostAddressScan.HOST_IFACE_PREFIXES; private final ServerContext context; private final List listeners = new CopyOnWriteArrayList<>(); private final List hostIpListeners = new CopyOnWriteArrayList<>(); diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/FirewallHelper.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/FirewallHelper.java index 9d383b58..4f973d9e 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/FirewallHelper.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/FirewallHelper.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/HostAddressScan.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/HostAddressScan.java new file mode 100644 index 00000000..a1964e9c --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/HostAddressScan.java @@ -0,0 +1,135 @@ +// 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.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import cn.classfun.droidvm.lib.Constants; + +/** + * The phone's own addresses, as a list something can be asked to listen on. + * + *

    Both 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 List HOST_IFACE_PREFIXES = List.of( + "wlan", "rmnet_data", "tun", "eth", + "ap", "swlan", "softap", "rndis", "usb", "bt-pan" + ); + + private HostAddressScan() { + } + + /** + * Every address the phone itself holds, as {@code [{addr, ifname, family}]} -- v4 and v6, + * global scope only, in netlink's dump order. Empty on any failure, which the caller shows as + * "nothing was found" rather than as an error: the picker's two fixed entries and its custom + * dialog work without this list. + * + *

    Four things are dropped, and the first two are the whole reason this is not done in the + * app process:

    + * + *
      + *
    • pbridge's offload-proxy addresses. An L2 pseudo-bridged guest's IP is parked on the + * phone's uplink so the Wi-Fi firmware answers ARP/NS for it, which makes it look exactly + * like an address of the phone's own. It is tagged with {@link Constants#PBRIDGE_OFFLOAD_MAGIC} + * as IFA_RT_PRIORITY for precisely this -- and that tag is netlink-only, invisible to + * {@code java.net.NetworkInterface} and to OEM {@code ip -j} builds too old to emit it.
    • + *
    • a host-route-only address ({@code noprefixroute} on a /32 or /128), which is the shape + * pbridge parks, so a proxy address from a build that predates the tag is caught anyway. Both + * halves are required: a plain /32 is how plenty of cellular interfaces are configured, and + * dropping those would lose the phone's real address.
    • + *
    • IPv6 privacy addresses ({@code temporary}). There are normally several at once -- + * the live one plus however many are still inside their valid lifetime and deprecated -- and + * every one of them is replaced within hours, so naming one in a config is naming something + * that will be gone. The address kept is the stable one the phone keeps alongside them. The + * flag is IFA_F_SECONDARY, which on v4 means an ordinary secondary address and is not + * dropped; netbox reports the v6 reading only.
    • + *
    • bridge devices -- ours, holding a VM network's gateway address rather than the + * phone's.
    • + *
    • anything outside {@link #HOST_IFACE_PREFIXES}, and any address that is not global + * scope: that is where link-local (fe80::/10, 169.254/16) and loopback go, neither of which + * is an address the phone can be reached at.
    • + *
    + */ + @NonNull + public static JSONArray list() { + var out = new JSONArray(); + var bridges = bridgeNames(); + var rows = Netbox.addrList(null, null); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null) continue; + var addr = r.optString("local", ""); + var ifname = r.optString("ifname", ""); + if (addr.isEmpty() || ifname.isEmpty()) continue; + if (!"global".equals(r.optString("scope", ""))) continue; + if (bridges.contains(ifname)) continue; + if (!isHostIface(ifname)) continue; + if (r.optLong("metric", 0) == Constants.PBRIDGE_OFFLOAD_MAGIC) continue; + var family = r.optInt("family", 0); + if (family != 4 && family != 6) continue; + if (r.optBoolean("temporary", false)) continue; + var prefixlen = r.optInt("prefixlen", 0); + if (r.optBoolean("noprefixroute", false) + && prefixlen == (family == 4 ? 32 : 128)) continue; + try { + var entry = new JSONObject(); + entry.put("addr", addr); + entry.put("ifname", ifname); + entry.put("family", family); + out.put(entry); + } catch (Exception e) { + Log.w(TAG, fmt("Failed to build host address entry for %s", addr), e); + } + } + return out; + } + + private static boolean isHostIface(@NonNull String ifname) { + for (var prefix : HOST_IFACE_PREFIXES) + if (ifname.startsWith(prefix)) return true; + return false; + } + + @NonNull + private static Set bridgeNames() { + var names = new HashSet(); + var rows = Netbox.linkList(null, true); + for (int i = 0; i < rows.length(); i++) { + var r = rows.optJSONObject(i); + if (r == null) continue; + var name = r.optString("ifname", ""); + if (!name.isEmpty()) names.add(name); + } + return names; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxBridgeBackend.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxBridgeBackend.java index f33e4e40..c35df558 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxBridgeBackend.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxBridgeBackend.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.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxNetwork.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxNetwork.java index 0ae109da..5ace9abd 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxNetwork.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/LinuxNetwork.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.RunUtils.run; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/ManagedProcess.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/ManagedProcess.java index 59b7952c..379c8ea9 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/ManagedProcess.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/ManagedProcess.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.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Netbox.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Netbox.java index 3d08b078..0a0e58ba 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Netbox.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Netbox.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.AssetUtils.getAssetBinaryPath; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Pbridge.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Pbridge.java index 2d91c8b7..5a2684bd 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Pbridge.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/Pbridge.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.*; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/UplinkResolver.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/UplinkResolver.java index 402f8ab4..00744e25 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/UplinkResolver.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/UplinkResolver.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.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvisorBridgeBackend.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvisorBridgeBackend.java index e6c8bb6c..95e42cc5 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvisorBridgeBackend.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvisorBridgeBackend.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.gvisor; import static cn.classfun.droidvm.lib.Constants.DATA_DIR; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchClient.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchClient.java index 23b598f0..379107f3 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchClient.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchClient.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.gvisor; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchConfigBuilder.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchConfigBuilder.java index 01e16c22..e48a5577 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchConfigBuilder.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/gvisor/GvswitchConfigBuilder.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.gvisor; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/ChainInfo.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/ChainInfo.java index 053e8187..37db8748 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/ChainInfo.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/ChainInfo.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.iptables; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesBackend.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesBackend.java index f98e19de..0df24ede 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesBackend.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesBackend.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.iptables; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesNetworkInstance.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesNetworkInstance.java index a8224c47..b8e8424d 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesNetworkInstance.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/iptables/IptablesNetworkInstance.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.iptables; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/pd/Duid.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/pd/Duid.java index 844e0ba2..3ee600f2 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/pd/Duid.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/pd/Duid.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.pd; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientHandler.java index c5474f24..bd0a01b8 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientHandler.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.server; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientRequest.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientRequest.java index c5286302..273b21da 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientRequest.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientRequest.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.server; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientResponse.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientResponse.java index b57ba0dd..4fafb226 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientResponse.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/ClientResponse.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.server; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestException.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestException.java index bb5bbde8..39a68e4f 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestException.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestException.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.server; public final class RequestException extends RuntimeException { diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandler.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandler.java index 5e0a1b6b..373938c1 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandler.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.server; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandlerStore.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandlerStore.java index 0aa60ebc..516cc8df 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandlerStore.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/RequestHandlerStore.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.server; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/Server.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/Server.java index 061b554e..263774d9 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/Server.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/Server.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.server; import static android.system.Os.stat; @@ -106,6 +109,13 @@ public void run() { return; } writePortFile(sockAddr.getPort()); + // Only now, with the port file written and a listener up. Auto-start waits on the + // huge-page reserve, and holding the daemon's whole reason for existing behind VMs that + // have not booted yet is what it used to do from the ServerContext constructor. By here + // the event callback is wired (our own constructor) and INT/TERM have handlers (Daemon's + // main, before this call), so the sweep's VMs report their states and a shutdown arriving + // mid-sweep is answered rather than ignored. + context.getVMs().autoUpAsync(); try { Log.d(TAG, fmt("DroidVM Daemon is listening on %s", sockAddr.toString())); runningThread = Thread.currentThread(); diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/server/ServerContext.java b/app/src/main/java/cn/classfun/droidvm/daemon/server/ServerContext.java index 30607aab..9e1b1429 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/server/ServerContext.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/server/ServerContext.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.server; import static cn.classfun.droidvm.lib.Constants.DATA_DIR; @@ -17,6 +20,7 @@ import cn.classfun.droidvm.daemon.network.NetworkInstanceStore; import cn.classfun.droidvm.daemon.network.backend.DefaultRouterWatcher; +import cn.classfun.droidvm.daemon.vm.VMInstance; import cn.classfun.droidvm.daemon.vm.VMInstanceStore; import cn.classfun.droidvm.daemon.vm.pkg.VMExportTask; import cn.classfun.droidvm.daemon.vm.pkg.VMImportTask; @@ -30,14 +34,25 @@ public final class ServerContext { private final Map exportTasks = new ConcurrentHashMap<>(); private final Map importTasks = new ConcurrentHashMap<>(); public DataItem appConfig = DataItem.newObject(); + /** + * Where VM events go. Here rather than on the store because loading vms.json builds every + * VMInstance against a throwaway store (see VMInstanceStore.createEmpty) and the callback is + * installed after the load -- so a store-owned field reached none of the VMs that existed at + * startup, which is all of them, and every state change, reboot and exit was fired into a + * null. The context is the one object both stores share, so putting it here cannot go stale. + */ + public volatile VMInstance.VMEventCallback vmEventCallback = null; public ServerContext() { Log.i(TAG, "loading config files..."); var filesDir = pathJoin(DATA_DIR, "files"); - vms.load(new File(filesDir, vms.getFileName())); + // Networks first, and wire the store in BEFORE loading VMs: loading builds each VMInstance + // against a throwaway store (see VMInstanceStore.createEmpty), so the link has to exist by + // then or the instances never see it. networks.load(new File(filesDir, networks.getFileName())); - Log.i(TAG, fmt("config files loaded: %d VMs, %d networks", vms.size(), networks.size())); vms.setNetworkStore(networks); + vms.load(new File(filesDir, vms.getFileName())); + Log.i(TAG, fmt("config files loaded: %d VMs, %d networks", vms.size(), networks.size())); // Strays survive a daemon crash or a forced (SIGKILL) takeover: the // children are orphaned, not killed. Reap any left over from a previous // daemon before we start fresh and auto-up. VM backends are matched by @@ -58,11 +73,10 @@ public ServerContext() { } catch (Exception e) { Log.w(TAG, "Failed to auto up networks", e); } - try { - vms.autoUp(); - } catch (Exception e) { - Log.w(TAG, "Failed to auto up VMs", e); - } + // VMs are NOT started here. Their sweep waits on the huge-page reserve -- seconds a VM, and + // the pkill above is what makes the reserve short -- and everything the daemon is for comes + // after this constructor: the socket, the signal handlers, the VM event callback. It runs + // from Server.run() instead, behind all three. See VMInstanceStore.autoUpAsync. try { routerWatcher.start(); } catch (Exception e) { diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/BootPlan.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/BootPlan.java index 39a097b8..66f5c792 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/BootPlan.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/BootPlan.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 static java.util.concurrent.TimeUnit.MILLISECONDS; @@ -57,7 +60,14 @@ public final class BootPlan { private static final long LBX_TIMEOUT_MS = 30_000; public final boolean uefi; - /** Custom UEFI firmware path; empty = builtin (QEMU honors, crosvm ignores). */ + /** + * Custom UEFI firmware path; empty = the backend's builtin EDK2. + * + *

    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 Map owners = new HashMap<>(); + + private UsbAcmPool() { + } + + /** A configfs edit that must run while the UDC is unbound. */ + private interface GadgetOp { + void run() throws Exception; + } + + /** + * A slot that exists but cannot be attached right now: held by another running VM, or + * outside the configured pool. Callers refuse the VM start (a silently reassigned COM + * port on the host is worse than not booting) instead of degrading to a sink. + */ + public static final class SlotUnavailableException extends IOException { + SlotUnavailableException(@NonNull String message) { + super(message); + } + } + + /** Whether the feature is switched on at all; without it every acquire refuses. */ + public static boolean enabledOf(@NonNull DataItem appConfig) { + return appConfig.optBoolean(KEY_USB_ACM_ENABLE, DEFAULT_ENABLE); + } + + /** The configured pool size, clamped to what the gadget can actually carry. */ + public static int portsOf(@NonNull DataItem appConfig) { + var n = (int) appConfig.optLong(KEY_USB_ACM_PORTS, DEFAULT_PORTS); + return Math.max(1, Math.min(MAX_PORTS, n)); + } + + /** + * Brings the pool in line with the app config: builds it when the feature is enabled, + * tears the unowned members down when it is not. Called whenever the daemon receives the + * app config, so toggling the setting takes effect without a VM start. Failures only log: + * the config write itself must not fail over gadget trouble. + */ + public static synchronized void applyConfig(@NonNull DataItem appConfig) { + try { + reconcile(enabledOf(appConfig) ? portsOf(appConfig) : 0); + } catch (IOException e) { + Log.w(TAG, "USB ACM pool reconcile failed", e); + } + } + + /** + * Attaches pool slot {@code slot} to {@code owner} and returns its {@code /dev/ttyGSn}. + * The slot is part of the VM config, not first-free: boot order must never decide which + * host COM port a VM lands on. Builds or repairs the pool first -- the only paths that + * rebind the UDC. Throws {@link SlotUnavailableException} when the slot is taken or out + * of range (refuse the boot), plain {@link IOException} when the gadget itself is + * unusable (degrade to sink). + */ + @NonNull + public static synchronized String acquire( + int slot, @NonNull String owner, @NonNull DataItem appConfig + ) throws IOException { + if (!enabledOf(appConfig)) + throw new SlotUnavailableException( + "USB serial (ACM) is disabled; enable it in the app settings first"); + var poolSize = portsOf(appConfig); + if (slot < 0 || slot >= poolSize) + throw new SlotUnavailableException(fmt( + "USB serial slot %d is outside the pool (size %d); raise the USB serial" + + " ports setting or pick a lower slot", slot, poolSize)); + var instance = fmt("%s%d", INSTANCE_PREFIX, slot); + var holder = owners.get(instance); + if (holder != null) + throw new SlotUnavailableException(fmt( + "USB serial slot %d is busy (held by %s)", slot, holder)); + reconcile(poolSize); + var devPath = devPathOf(instance); + waitForNode(devPath); + owners.put(instance, owner); + Log.i(TAG, fmt("acm slot %d (%s) -> %s", slot, devPath, owner)); + return devPath; + } + + /** Releases every slot {@code owner} holds. Never rebinds; the member just idles. */ + public static synchronized void release(@NonNull String owner) { + owners.values().removeIf(owner::equals); + } + + /** + * Brings the gadget to exactly {@code poolSize} pool members (0 = feature off): members + * below the size exist and are linked, members at or above it are removed -- except ones a + * running VM holds, which are left alone (yanking a function under an open crosvm fd + * hangs the port up). All link edits share a single unbind/bind window; when nothing has + * to change there is no rebind at all. Only functions named {@code acm.dvmpool*} are ever + * touched: another app's (or the vendor's) acm/gser functions share the ttyGS number + * space, and grabbing whatever exists would fight them -- ownership is by name, always. + */ + private static void reconcile(int poolSize) throws IOException { + var toRemove = new java.util.ArrayList(); + var namePrefix = fmt("acm.%s", INSTANCE_PREFIX); + var existing = new File(FUNCTIONS_DIR) + .listFiles(f -> f.getName().startsWith(namePrefix)); + if (existing != null) for (var funcDir : existing) { + var instance = funcDir.getName().substring("acm.".length()); + int index; + try { + index = Integer.parseInt(instance.substring(INSTANCE_PREFIX.length())); + } catch (NumberFormatException e) { + continue; + } + if (index >= poolSize && !owners.containsKey(instance)) + toRemove.add(funcDir); + } + var missingLink = false; + for (int i = 0; i < poolSize; i++) { + var funcDir = funcDirOf(fmt("%s%d", INSTANCE_PREFIX, i)); + // mkdir works while bound; only the config links need the unbound window. + if (!funcDir.isDirectory() && !funcDir.mkdir()) + throw new IOException(fmt("cannot create %s", funcDir)); + if (!new File(CONFIG_DIR, funcDir.getName()).exists()) + missingLink = true; + } + if (!missingLink && toRemove.isEmpty()) return; + IOException lastFailure = null; + for (int attempt = 1; attempt <= UDC_RETRIES; attempt++) { + awaitUsbSettled(); + try { + withUdcUnbound(() -> { + for (int i = 0; i < poolSize; i++) { + var funcDir = funcDirOf(fmt("%s%d", INSTANCE_PREFIX, i)); + var link = new File(CONFIG_DIR, funcDir.getName()); + if (!link.exists()) + Os.symlink(funcDir.getAbsolutePath(), link.getAbsolutePath()); + } + for (var funcDir : toRemove) { + deleteFile(new File(CONFIG_DIR, funcDir.getName()).getAbsolutePath()); + if (funcDir.isDirectory() && !funcDir.delete()) + Log.w(TAG, fmt("cannot remove %s", funcDir)); + } + }); + lastFailure = null; + break; + } catch (IOException e) { + lastFailure = e; + Log.w(TAG, fmt("gadget reconcile attempt %d/%d lost the UDC race", + attempt, UDC_RETRIES), e); + } + } + if (lastFailure != null) throw lastFailure; + Log.i(TAG, fmt("USB ACM pool reconciled to %d members", poolSize)); + } + + @NonNull + private static File funcDirOf(@NonNull String instance) { + return new File(FUNCTIONS_DIR, fmt("acm.%s", instance)); + } + + /** + * The member's character device. The kernel assigns the ttyGS index at function creation + * and reports it in port_num -- the instance name says nothing about it (gser/acm share + * one number space, and other functions may hold lower indexes). + */ + @NonNull + private static String devPathOf(@NonNull String instance) throws IOException { + try { + var portNum = readFile(new File(funcDirOf(instance), "port_num")).trim(); + return fmt("/dev/ttyGS%d", Integer.parseInt(portNum)); + } catch (NumberFormatException e) { + throw new IOException("cannot parse acm port_num", e); + } + } + + /** The node only appears after the gadget binds, not at function-creation time. */ + private static void waitForNode(@NonNull String devPath) throws IOException { + var node = new File(devPath); + for (int waited = 0; !node.exists(); waited += NODE_POLL_MS) { + if (waited >= NODE_WAIT_MS) + throw new IOException(fmt("%s did not appear after bind", devPath)); + threadSleep(NODE_POLL_MS); + } + } + + /** + * True when the USB stack looks idle: boot is done and the UDC binding holds one steady + * non-empty value across a short probe. An unbound UDC means the HAL is mid-transition + * (or about to be) -- exactly the moment an edit window would race it. + */ + private static boolean usbSettled() { + if (!"1".equals(getprop("sys.boot_completed"))) return false; + String first; + try { + first = readFile(UDC_FILE).trim(); + } catch (IOException e) { + return false; + } + if (first.isEmpty()) return false; + threadSleep(SETTLE_PROBE_MS); + try { + return first.equals(readFile(UDC_FILE).trim()); + } catch (IOException e) { + return false; + } + } + + /** Waits for {@link #usbSettled()}, but never forever: a stack that will not settle gets + * one polite attempt anyway rather than a pool that silently never appears. */ + private static void awaitUsbSettled() { + var deadline = System.currentTimeMillis() + SETTLE_TIMEOUT_MS; + while (System.currentTimeMillis() < deadline) { + if (usbSettled()) return; + threadSleep(SETTLE_POLL_MS); + } + Log.w(TAG, "USB stack did not settle in time; attempting the gadget edit anyway"); + } + + @NonNull + private static String getprop(@NonNull String key) { + return runListQuiet("getprop", key).getOutString().trim(); + } + + /** + * Runs a configfs edit inside an unbind/bind window, back-to-back with no waiting in + * between, and with the bind in a finally so a failed edit never strands the gadget + * unbound (which would kill adb-over-USB for good). + */ + private static void withUdcUnbound(@NonNull GadgetOp op) throws IOException { + var udc = ""; + try { + udc = readFile(UDC_FILE).trim(); + } catch (IOException ignored) { + } + if (udc.isEmpty()) { + var names = new File(UDC_CLASS_DIR).list(); + if (names == null || names.length == 0) + throw new IOException("no UDC available"); + udc = names[0]; + } + try { + // A newline, not an empty string: zero bytes never reach the kernel's UDC store + // at all (the write is dropped at the VFS), so "" silently leaves the gadget + // bound -- and every config edit below then fails with EINVAL. echo does the + // same thing; the store strips the newline and treats it as unbind. + writeFile(UDC_FILE, "\n"); + } catch (IOException ignored) { + // Already unbound; the readback below decides. + } + var check = ""; + try { + check = readFile(UDC_FILE).trim(); + } catch (IOException ignored) { + } + if (!check.isEmpty()) + throw new IOException(fmt("gadget did not unbind (UDC still %s)", check)); + try { + op.run(); + } catch (Exception e) { + throw e instanceof IOException ? (IOException) e : new IOException(e); + } finally { + try { + writeFile(UDC_FILE, udc); + } catch (IOException e) { + // EBUSY here means the racing HAL bound it first. If our edits landed before + // that bind the gadget is up with them anyway, so only propagate a rebind + // failure when the UDC is genuinely left unbound. + var now = ""; + try { + now = readFile(UDC_FILE).trim(); + } catch (IOException ignored) { + } + if (now.isEmpty()) throw e; + } + } + } + +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMBackendInstance.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMBackendInstance.java index 28f4b4ea..6cb90ae3 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMBackendInstance.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMBackendInstance.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 static cn.classfun.droidvm.lib.Constants.DATA_DIR; @@ -21,6 +24,7 @@ import cn.classfun.droidvm.daemon.console.ConsoleStream; import cn.classfun.droidvm.daemon.server.ServerContext; import cn.classfun.droidvm.lib.natives.NativeProcess; +import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.lib.store.vm.VMConfig; import cn.classfun.droidvm.lib.utils.FileUtils; @@ -47,9 +51,11 @@ protected VMBackendInstance(@NonNull ServerContext context, @NonNull VMConfig co /** * Writes pre-encoded evdev bytes to the running backend's native-display input channel on - * behalf of the UI. Only the crosvm backend implements this; others report not-delivered. + * behalf of the UI. [screenId] is the screen the console sending them is showing; it selects + * the device for the absolute channels and is ignored by the VM-wide ones. Only the crosvm + * backend implements this; others report not-delivered. */ - public boolean writeNativeInput(int channel, @NonNull byte[] data) { + public boolean writeNativeInput(@NonNull String screenId, int channel, @NonNull byte[] data) { return false; } @@ -71,6 +77,26 @@ private void cleanUpMemory() { run("echo 1 > /proc/sys/vm/compact_memory"); } + private void applyConfiguredEnvironment(@NonNull NativeProcess.Builder builder) { + var environment = config.item.opt("environment_variables", DataItem.newArray()); + if (environment == null || !environment.is(DataItem.Type.ARRAY)) return; + for (var entry : environment.asArray()) { + if (entry == null || !entry.is(DataItem.Type.STRING)) continue; + var raw = entry.asString().trim(); + var separator = raw.indexOf('='); + if (separator <= 0) { + Log.w(TAG, "Ignoring invalid VM environment variable entry"); + continue; + } + var key = raw.substring(0, separator).trim(); + if (key.isEmpty()) { + Log.w(TAG, "Ignoring VM environment variable with an empty name"); + continue; + } + builder.environment(key, raw.substring(separator + 1)); + } + } + protected void prepareProcess(@NonNull NativeProcess.Builder builder) { String[] preload = { pathJoin(DATA_DIR, "lib", "libsimpledump.so"), @@ -78,6 +104,7 @@ protected void prepareProcess(@NonNull NativeProcess.Builder builder) { }; builder.environment("LD_PRELOAD", String.join(":", preload)); builder.environment("LD_LIBRARY_PATH", pathJoin(DATA_DIR, "usr", "lib")); + applyConfiguredEnvironment(builder); builder.maxOpenFiles(65536); builder.maxLockedMemory(RLIM_INFINITY); cleanUpMemory(); diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMInstance.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMInstance.java index 9da40e02..28d3f2be 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMInstance.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMInstance.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 static java.util.Objects.requireNonNull; @@ -17,6 +20,8 @@ import org.json.JSONException; import org.json.JSONObject; +import java.io.File; +import java.io.FileOutputStream; import java.util.ArrayList; import java.util.List; import java.util.UUID; @@ -24,10 +29,13 @@ import cn.classfun.droidvm.daemon.console.ConsoleStream; import cn.classfun.droidvm.daemon.vm.backend.BackendBase; import cn.classfun.droidvm.lib.data.CrosvmExit; +import cn.classfun.droidvm.lib.hugepage.PoolPreflight; import cn.classfun.droidvm.lib.natives.NativeProcess; import cn.classfun.droidvm.lib.natives.UnixHelper; import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.lib.store.network.NetworkState; +import cn.classfun.droidvm.lib.store.vm.DisplayExporter; +import cn.classfun.droidvm.lib.store.vm.VMScreenConfig; import cn.classfun.droidvm.lib.store.vm.VMBackend; import cn.classfun.droidvm.lib.store.vm.VMConfig; import cn.classfun.droidvm.lib.store.vm.VMNicConfig; @@ -36,7 +44,14 @@ public final class VMInstance extends VMConfig { private static final String TAG = "VMInstance"; - private VMState state = VMState.STOPPED; + /** + * Read from every thread that asks what this VM is doing -- IPC handlers, the auto-start + * sweep, stopAll, the worker itself -- and written by the worker and by start(). Volatile so + * that a reader outside {@link #startLock} sees the current answer rather than a cached one. + */ + private volatile VMState state = VMState.STOPPED; + /** Serialises the run-up to STARTING against a second caller; see {@link #start}. */ + private final Object startLock = new Object(); private NativeProcess process; private boolean stoppedByUser = false; private int exitCode = -1; @@ -47,6 +62,10 @@ public final class VMInstance extends VMConfig { private BootPlan bootPlan; private String bootEntryOverride; private volatile boolean rebootRequested = false; + /** Set while a start that failed on unpinnable memory is being tried a second time. */ + private volatile boolean pinRetryUsed = false; + /** A run this long counts as "the VM started", re-arming the one-shot pin retry. */ + private static final long PIN_RETRY_RESET_MS = 60_000; // Delay before relaunching on reboot: lets the kernel settle same-name TAP // teardown/recreate and throttles a guest reboot-loop (no restart cap). @@ -88,12 +107,16 @@ synchronized VMBackendInstance getBackendInstance() { return this.backendInstance; } - /** Forwards UI-sent evdev bytes to the running backend's native-display input channel. */ - public boolean writeNativeInput(int channel, @NonNull byte[] data) { + /** + * Forwards UI-sent evdev bytes to the running backend's native-display input channel for + * [screenId] -- the screen the console that sent them is showing, which is what picks between + * two screens' absolute devices. + */ + public boolean writeNativeInput(@NonNull String screenId, int channel, @NonNull byte[] data) { // Only a running VM has a backend with bound input sockets; skip otherwise so a stale or // spoofed input call can't lazily create an idle backend instance. if (state != VMState.RUNNING) return false; - return getBackendInstance().writeNativeInput(channel, data); + return getBackendInstance().writeNativeInput(screenId, channel, data); } @NonNull @@ -110,10 +133,13 @@ private void setState(@NonNull VMState newState) { this.state = newState; Log.i(TAG, fmt("VM %s [%s] -> %s", getName(), getId().toString(), newState.name())); fireEvent("state", null); + // The one place every transition passes through, so the foreground service a peripheral + // may need is raised and dropped from the same edge the guest device appears on. + PeripheralForegroundControl.refresh(store); } private void fireEvent(@NonNull String event, @Nullable JSONObject extra) { - var cb = store.eventCallback; + var cb = store.context.vmEventCallback; if (cb == null) return; try { var data = new JSONObject(); @@ -152,29 +178,43 @@ public void setBootEntryOverride(@Nullable String entryId) { bootEntryOverride = entryId; } + /** + * Takes this VM from STOPPED to STARTING and hands it to a worker thread. + * + *

    Held 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, List createdNics, @NonNull DataItem netCfg, String vmId) throws Exception { @@ -248,20 +288,39 @@ private void detachNic(@NonNull VMNicConfig nic) { } } + /** + * Fills in whatever each VNC-bound screen left unset before the VM starts. + * + *

    Per 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 ArrayList(); + for (var screen : VMScreenConfig.listOf(item)) { + if (!screen.isEnabled() || screen.getExporter() != DisplayExporter.VNC) continue; + var port = screen.getVncPort(); + if (port <= 0 || taken.contains(port)) { + int fresh = generateRandomAvailablePort(); + while (fresh > 0 && taken.contains((long) fresh)) + fresh = generateRandomAvailablePort(); + if (fresh > 0) { + screen.setVncPort(fresh); + port = fresh; + Log.i(TAG, fmt("VM %s: auto-assigned VNC port %d for screen %s", + getName(), fresh, screen.id)); + } else { + Log.e(TAG, fmt("VM %s: failed to find available VNC port for screen %s", + getName(), screen.id)); + } + } + if (port > 0) taken.add(port); + if (screen.isVncPasswordAuth() && screen.getVncPassword().isEmpty()) { + screen.setVncPassword(generateRandomPassword(8)); + Log.i(TAG, fmt("VM %s: auto-generated VNC password for screen %s", + getName(), screen.id)); } - } - if (item.optBoolean("vnc_password_auth", false) && item.optString("vnc_password", "").isEmpty()) { - var password = generateRandomPassword(8); - item.set("vnc_password", password); - Log.i(TAG, fmt("VM %s: auto-generated VNC password", getName())); } } @@ -409,7 +468,9 @@ private void runVM() { startReaderThread(vmId, stream); } setState(VMState.RUNNING); + long ranFrom = System.currentTimeMillis(); int code = process.waitFor(); + long ranForMs = System.currentTimeMillis() - ranFrom; for (var stream : inst.streams.values()) { var reader = stream.getReaderThread(); if (reader != null && reader.isAlive()) { @@ -430,6 +491,24 @@ private void runVM() { // A guest-requested reset (crosvm exit 32) or a host-issued reboot both // relaunch the VM; an explicit user stop never does and wins over both. boolean wantRestart = !stoppedByUser && (code == CrosvmExit.RESET.getCode() || rebootRequested); + // ... and so does a start that died because the memory it was given could not be pinned. + // That is the VMM refusing to hand the hypervisor pages the host may still move, and it + // is worth exactly one more attempt: the condition is a moment in time (the reserve had + // not finished taking the previous VM's pages back, or the allocation drew from CMA), and + // measured on device the identical configuration started cleanly on the retry. Beyond one + // attempt it is not a moment, it is a shortage, and repeating would only hide it. + // RUNNING is set the moment the process is spawned, so it says nothing about whether the + // VM got off the ground: a pin refusal happens inside VM creation, seconds later. A start + // that lasted is what clears the one-shot, so a VM that ran for an hour and then died can + // still be retried, while a VM failing in three seconds cannot loop. + if (code == 0 || ranForMs > PIN_RETRY_RESET_MS) pinRetryUsed = false; + boolean pinFailure = !stoppedByUser && !wantRestart && code != 0 && !pinRetryUsed + && exitLogSuggestsPinFailure(); + if (pinFailure) { + pinRetryUsed = true; + wantRestart = true; + Log.w(TAG, fmt("VM %s exited on unpinnable memory; retrying once", getName())); + } if (stoppedByUser && code != 0) { Log.i(TAG, fmt("VM %s stopped by user", getName())); exitCode = 0; @@ -450,12 +529,50 @@ private void runVM() { scheduleRelaunch(); return; } + nprocGuardResetBestEffort(); setState(VMState.STOPPED); fireEvent("exited", null); } + /** + * Best-effort, run by the daemon once a VM's process has fully exited: ask nproc_guard to + * recompute the app uid's RLIMIT_NPROC ucounts counter back to its true live count. crosvm + * drops its real uid to the app's (setresuid, to reach the camera/mic/app-scoped files), and + * on some kernels that real-uid switching leaves the per-uid NPROC accounting slightly off; + * left to drift across many VM runs it eventually blocks the app from launching until a + * reboot. Doing it here -- process gone, uid idle -- is the exact, race-free moment to correct + * it. The guard module is loaded with this app's uid, so a bare "1" is enough; if it is not + * loaded the sysfs node is absent and this is a no-op. + */ + private static void nprocGuardResetBestEffort() { + var reset = new File("/sys/kernel/nproc_guard/reset"); + if (!reset.exists()) + return; + try (var os = new FileOutputStream(reset)) { + os.write('1'); + } catch (Exception e) { + Log.d(TAG, "nproc_guard reset skipped", e); + } + } + // Relaunch off the worker thread: start() joins workerThread (this thread), // and the short sleep settles same-name TAP recreation before re-setup. + /** + * Whether this exit was the VMM refusing memory it could not pin (see crosvm's pin.rs). + * + * Read off the console rather than the exit code, because there is no distinct code for it: + * crosvm reports a plain failure to create the VM. The two lines below are the ones that + * distinguish it from every other start failure -- a bad disk path, a missing kernel -- which + * must not be retried. + */ + private boolean exitLogSuggestsPinFailure() { + var stream = getStream("stdio"); + if (stream == null) return false; + var log = stream.getBuffer(); + if (log == null) return false; + return log.contains("GH-PIN[") && log.contains("cannot be long-term pinned"); + } + private void scheduleRelaunch() { var t = new Thread(() -> { try { @@ -464,6 +581,17 @@ private void scheduleRelaunch() { return; } if (state != VMState.REBOOTING) return; // user changed state meanwhile + // The VM we are relaunching has only just let go of its memory, and the huge-page + // reserve takes a few seconds to get it back. Relaunching into that gap is the one + // way a reboot turns into a dead VM: the pages come from ordinary movable memory + // instead, the VMM refuses to hand memory it cannot pin to the hypervisor, and the + // relaunch exits with ENOMEM (measured: reserve at 526 of 2542 pages when the + // relaunch started, full again nine seconds later). Nobody is watching a reboot, so + // it waits like any other background start -- see PoolPreflight.waitForPool. + if (!PoolPreflight.waitForPool(item, PoolPreflight.RELAUNCH_ATTEMPTS, + PoolPreflight.BACKGROUND_INTERVAL_MS, PoolPreflight.BACKGROUND_ACQUIRE_AT)) + Log.w(TAG, fmt("VM %s relaunching with the reserve still short", getName())); + if (state != VMState.REBOOTING) return; // stopped while we waited if (!start()) { Log.w(TAG, fmt("VM %s relaunch failed", getName())); // Surface the failure as a real exit so attached consoles / UI stop diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMInstanceStore.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMInstanceStore.java index b8523620..e4f667b4 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMInstanceStore.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMInstanceStore.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 static cn.classfun.droidvm.daemon.vm.VMInstance.getVMInstance; @@ -14,6 +17,7 @@ import java.util.ArrayList; +import cn.classfun.droidvm.lib.hugepage.PoolPreflight; import cn.classfun.droidvm.daemon.network.NetworkInstanceStore; import cn.classfun.droidvm.daemon.server.ServerContext; import cn.classfun.droidvm.daemon.vm.backend.BackendBase; @@ -24,9 +28,19 @@ public final class VMInstanceStore extends DataStore { private static final String TAG = "VMInstanceStore"; + /** How long {@link #stopAll} gives the auto-start sweep to stand down. */ + private static final long AUTO_UP_JOIN_MS = 3000; public final ServerContext context; - volatile VMInstance.VMEventCallback eventCallback = null; NetworkInstanceStore networkStore; + /** + * Set once the daemon is going down, and never cleared: the sweep reads it to decide whether + * there is any point starting the next VM. A store that has been through {@link #stopAll} is + * not one anything should be starting VMs in again. + */ + private volatile boolean shuttingDown = false; + /** The auto-start sweep, if one was ever started. See {@link #autoUpAsync}. */ + @Nullable + private volatile Thread autoUpThread = null; public VMInstanceStore(@NonNull ServerContext context) { super(); @@ -36,7 +50,7 @@ public VMInstanceStore(@NonNull ServerContext context) { } public void setEventCallback(@Nullable VMInstance.VMEventCallback cb) { - this.eventCallback = cb; + context.vmEventCallback = cb; } public void setNetworkStore(@Nullable NetworkInstanceStore networkStore) { @@ -125,6 +139,10 @@ public JSONArray listVMs() { public void stopAll() { Log.i(TAG, "Stopping all VMs..."); + // Before anything is collected. The sweep reads this between VMs and inside its wait, so + // setting it first is what stops it handing us a VM to stop after we have looked. + shuttingDown = true; + joinAutoUp(); var toStop = new ArrayList(); forEach((id, inst) -> { var state = inst.getState(); @@ -140,13 +158,87 @@ public void stopAll() { clear(); } + /** + * Runs {@link #autoUp} on a thread of its own. + * + *

    The 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 ArrayList(); forEach((id, inst) -> { - if (!inst.item.optBoolean("auto_up", false) || inst.getState() != VMState.STOPPED) return; - Log.i(TAG, fmt("Auto-starting VM %s [%s]", inst.getName(), id)); - if (!inst.start()) - Log.w(TAG, fmt("Failed to auto-start VM %s [%s]", inst.getName(), id)); + if (inst.item.optBoolean("auto_up", false) && inst.getState() == VMState.STOPPED) + pending.add(inst); }); + for (var inst : pending) { + if (shuttingDown) { + Log.i(TAG, "the daemon is going down; abandoning the rest of the auto-start sweep"); + return; + } + PoolPreflight.waitForPool(inst.item, PoolPreflight.BACKGROUND_ATTEMPTS, + PoolPreflight.BACKGROUND_INTERVAL_MS, PoolPreflight.BACKGROUND_ACQUIRE_AT, + () -> shuttingDown); + // Read again: the VM may have been started by a client, or deleted, while we waited, + // and the daemon may have begun going down inside the wait itself. + if (shuttingDown) { + Log.i(TAG, "the daemon is going down; abandoning the rest of the auto-start sweep"); + return; + } + if (inst.getState() != VMState.STOPPED) continue; + Log.i(TAG, fmt("Auto-starting VM %s [%s]", inst.getName(), inst.getId())); + if (!inst.start()) + Log.w(TAG, fmt("Failed to auto-start VM %s [%s]", inst.getName(), inst.getId())); + } } @NonNull @@ -164,7 +256,14 @@ protected VMInstance create(@NonNull JSONObject obj) throws JSONException { @NonNull @Override protected DataStore createEmpty() { - return new VMInstanceStore(context); + var store = new VMInstanceStore(context); + // DataStore.load() parses into this throwaway store and then replace()s the items over, + // but each VMInstance keeps the store it was constructed with -- so whatever the loaded + // instances need to reach through their store has to be inherited here, or it is null for + // the rest of their life. That is how a VM with NICs ended up failing to start with + // "has networks but no network store". + store.networkStore = networkStore; + return store; } @NonNull diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMStartResult.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMStartResult.java index 4d292d0f..8284786f 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMStartResult.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/VMStartResult.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 cn.classfun.droidvm.lib.natives.NativeProcess; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/AppGroups.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/AppGroups.java new file mode 100644 index 00000000..4243d73e --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/AppGroups.java @@ -0,0 +1,165 @@ +// 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.backend; + +import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +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.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; + +import cn.classfun.droidvm.BuildConfig; + +/** + * The app's supplementary groups, for a device the VMM runs as the app instead of as root. + * + *

    Why 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//status}. The daemon runs as root, so it can read it. + * + *

    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//status}. */ + @Nullable + private static int[] parseGroups(String status) { + for (var line : status.split("\n")) { + if (!line.startsWith("Groups:")) continue; + var out = new ArrayList(); + for (var field : line.substring("Groups:".length()).trim().split("\\s+")) { + if (field.isEmpty()) continue; + try { + out.add(Integer.parseInt(field)); + } catch (NumberFormatException ignored) { + } + } + return toArray(out); + } + return null; + } + + private static int[] toArray(List list) { + var out = new int[list.size()]; + for (int i = 0; i < out.length; i++) out[i] = list.get(i); + return out; + } + + private static void persist(int[] gids) { + try { + writeFile(CACHE_PATH, join(gids)); + } catch (Throwable t) { + Log.w(TAG, "could not cache the app groups", t); + } + } + + @Nullable + private static int[] readCache() { + try { + var text = readFile(CACHE_PATH).trim(); + if (text.isEmpty()) return null; + var out = new ArrayList(); + for (var field : text.split(",")) { + field = field.trim(); + if (!field.isEmpty()) out.add(Integer.parseInt(field)); + } + return out.isEmpty() ? null : toArray(out); + } catch (Throwable t) { + return null; + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/BackendBase.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/BackendBase.java index 41de6564..85c3b4ba 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/BackendBase.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/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.vm.backend; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackend.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackend.java index 37185f3c..b42f1520 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackend.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackend.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.backend; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java index af305008..1cf64a73 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/CrosvmBackendInstance.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.backend; import static android.net.LocalSocketAddress.Namespace.FILESYSTEM; @@ -6,13 +9,12 @@ import static cn.classfun.droidvm.lib.Constants.PATH_EDK2_FIRMWARE; import static cn.classfun.droidvm.lib.Constants.PATH_EDK2_VARS; import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum; -import static cn.classfun.droidvm.lib.store.vm.DisplayBackend.SIMPLEFB; -import static cn.classfun.droidvm.lib.store.vm.DisplayBackend.VIRTIO_GPU; import static cn.classfun.droidvm.lib.store.vm.GpuApi.VULKAN; import static cn.classfun.droidvm.lib.utils.AssetUtils.getPrebuiltBinaryPath; import static cn.classfun.droidvm.lib.utils.FileUtils.deleteFile; 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.net.LocalSocket; import android.net.LocalSocketAddress; @@ -24,57 +26,106 @@ import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; +import cn.classfun.droidvm.BuildConfig; import cn.classfun.droidvm.daemon.console.FDPipeConsoleStream; import cn.classfun.droidvm.daemon.console.InputConsoleStream; import cn.classfun.droidvm.daemon.console.SimpleConsoleStream; +import cn.classfun.droidvm.daemon.display.DaemonSystemContext; import cn.classfun.droidvm.daemon.server.ServerContext; import cn.classfun.droidvm.daemon.vm.BootPlan; import cn.classfun.droidvm.daemon.vm.SerialPipe; +import cn.classfun.droidvm.daemon.vm.UsbAcmPool; import cn.classfun.droidvm.daemon.vm.VMBackendInstance; import cn.classfun.droidvm.daemon.vm.VMStartResult; +import cn.classfun.droidvm.daemon.audio.HostAudioTable; +import cn.classfun.droidvm.lib.data.HostAudioDevices; import cn.classfun.droidvm.lib.natives.NativeProcess; +import cn.classfun.droidvm.lib.utils.RunUtils; import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.lib.store.disk.DiskBus; -import cn.classfun.droidvm.lib.store.vm.DisplayBackend; +import cn.classfun.droidvm.lib.store.vm.CpuPlacementPlan; +import cn.classfun.droidvm.lib.store.vm.DisplayExporter; +import cn.classfun.droidvm.lib.store.vm.DisplayTransportCap; import cn.classfun.droidvm.lib.store.vm.GpuApi; +import cn.classfun.droidvm.lib.store.vm.GpuMode; +import cn.classfun.droidvm.lib.store.vm.GuestPoolSizing; import cn.classfun.droidvm.lib.store.vm.GpuBackend; +import cn.classfun.droidvm.lib.store.vm.GpuBlitProvider; import cn.classfun.droidvm.lib.store.vm.LendMthpMode; import cn.classfun.droidvm.lib.store.vm.NativeDisplay; +import cn.classfun.droidvm.lib.store.vm.PeripheralType; +import cn.classfun.droidvm.lib.store.vm.SerialBackend; +import cn.classfun.droidvm.lib.store.vm.SerialHardware; +import cn.classfun.droidvm.lib.store.vm.VMSerialConfig; +import cn.classfun.droidvm.lib.store.vm.SoundMode; import cn.classfun.droidvm.lib.store.vm.ProtectedVM; import cn.classfun.droidvm.lib.store.vm.SharedDirCache; import cn.classfun.droidvm.lib.store.vm.SharedDirType; import cn.classfun.droidvm.lib.store.vm.VMBackend; import cn.classfun.droidvm.lib.store.vm.VMConfig; import cn.classfun.droidvm.lib.store.vm.VMHypervisor; +import cn.classfun.droidvm.lib.store.vm.VMPeripheralConfig; +import cn.classfun.droidvm.lib.store.vm.VMScreenConfig; @SuppressWarnings("FieldCanBeLocal") public final class CrosvmBackendInstance extends VMBackendInstance { private static final String TAG = "CrosvmBackendInstance"; private static final String RUN_PATH = pathJoin(DATA_DIR, "run"); - private SerialPipe uart = null; private String controlSocketPath = null; + /** Set by prepareGpuCgroup() once the cpuset exists and holds cores; else null. */ + private String gpuCgroupPath = null; /** Owns the per-VM native-display input sockets (crosvm-facing + UI-facing); see start(). */ private final NativeDisplayInputBridge inputBridge = new NativeDisplayInputBridge(); - private final FDPipeConsoleStream uartStream; private final InputConsoleStream stdoutStream; private final InputConsoleStream stderrStream; private final SimpleConsoleStream stdioStream; + /** + * One configured serial port resolved for a run: its config plus whatever host resource + * backs it this time. The daemon owns every resource here; the UI only ever sees the + * config and the console stream. The pty backend holds nothing -- that is crosvm's own + * serial type. + */ + private static final class ResolvedSerial { + final VMSerialConfig port; + SerialPipe pipe; // APP_CONSOLE + String acmDevPath; // USB_ACM: pool member's ttyGSn that crosvm opens + + ResolvedSerial(@NonNull VMSerialConfig port) { + this.port = port; + } + } + + private final List resolvedSerials = new ArrayList<>(); + /** Console streams for APP_CONSOLE ports, keyed by stream name; registered once. */ + private final Map serialStreams = new LinkedHashMap<>(); + public CrosvmBackendInstance( @NonNull ServerContext context, @NonNull VMConfig config ) { super(context, config); - uartStream = new FDPipeConsoleStream(config, "uart", -1, -1); stdoutStream = new InputConsoleStream(config, "stdout", null); stderrStream = new InputConsoleStream(config, "stderr", null); stdioStream = new SimpleConsoleStream(config, "stdio"); addStream(stdoutStream); addStream(stderrStream); addStream(stdioStream); - addStream(uartStream); + // One text console per app-console serial port. Registered here, like the old fixed + // "uart" stream, so the stream list is stable across VM restarts. + VMSerialConfig.ensureDefaults(config.item); + for (var port : VMSerialConfig.listOf(config.item)) { + if (port.getBackend() != SerialBackend.APP_CONSOLE) continue; + var name = port.getStreamName(); + if (serialStreams.containsKey(name)) continue; + var stream = new FDPipeConsoleStream(config, name, -1, -1); + serialStreams.put(name, stream); + addStream(stream); + } } @NonNull @@ -84,15 +135,14 @@ public VMStartResult start() { if (!new File(RUN_PATH).mkdirs()) Log.w(TAG, fmt("Failed to create run directory: %s", RUN_PATH)); try { - uart = new SerialPipe(uartStream, "uart"); - if (!uart.isReady()) { - Log.w(TAG, "UART pipe not ready, discarding"); - uart.close(); - uart = null; - } - } catch (Exception e) { - Log.w(TAG, "Failed to create UART pipe", e); - uart = null; + resolveSerialPorts(); + } catch (IOException e) { + // A refused serial slot fails the whole start; the reason lands on the stdio + // console where every other boot failure already goes. + Log.e(TAG, "Serial setup refused", e); + stdioStream.appendBuffer(fmt("serial setup failed: %s\n", e.getMessage())); + closeSerialPorts(); + return result; } controlSocketPath = pathJoin(RUN_PATH, fmt("%s.sock", config.getName())); deleteFile(controlSocketPath); @@ -102,32 +152,56 @@ public VMStartResult start() { // the VM is up - so the daemon is the only process that can both bind the socket before // crosvm starts and stay alive to feed it. We pre-bind + accept here; the UI forwards evdev // to us via the vm_input IPC command (see InputHandler). Server fds released on cleanup(). - if (isNativeDisplayEnabled()) { - if (!inputBridge.startListening(NativeDisplay.serviceName(config))) { - Log.e(TAG, "Native display input sockets unavailable; crosvm will likely fail"); + // Single source of truth: isInputBridgeNeeded() gates both this pre-bind and the --input + // args in buildCommand(), and touchscreenScreens()/nativeInputScreens() decide which + // screens get which per-screen device in both places, so the sockets and devices never + // diverge. The two lists differ: a VNC-exported screen's tablet and keyboard are crosvm's, + // not ours. + if (isInputBridgeNeeded()) { + try { + if (!inputBridge.startListening(config.getId().toString(), + touchscreenScreens(), nativeInputScreens())) { + Log.e(TAG, "Display input sockets unavailable; crosvm will likely fail"); + } + } catch (IllegalArgumentException e) { + // A socket path too long for sun_path. crosvm would refuse the command line and + // our own bind() would truncate it in silence, so there is no half-working start + // to attempt: fail here, with the path and its length on the console the user is + // already looking at, the way a refused serial slot does. + Log.e(TAG, "Display input socket path refused", e); + stdioStream.appendBuffer(fmt("display input setup failed: %s\n", e.getMessage())); + inputBridge.release(); + closeSerialPorts(); + controlSocketPath = null; + return result; } } + // Must happen before buildCommand(): crosvm opens /tasks and never + // creates the directory, and buildCommand() only passes the flag if this worked. + prepareGpuCgroup(); var args = buildCommand(); Log.i(TAG, fmt("Executing: %s", String.join(" ", args))); try { var builder = new NativeProcess.Builder(args.toArray(new String[0])); prepareProcess(builder); - if (uart != null) { - builder.preserveFd(uart.getOutputRemoteFd()); - builder.preserveFd(uart.getInputRemoteFd()); + applyGfxstreamEnv(builder); + applyDisplayBlitEnv(builder); + applyGpuRtPrioEnv(builder); + for (var rs : resolvedSerials) { + if (rs.pipe != null) { + builder.preserveFd(rs.pipe.getOutputRemoteFd()); + builder.preserveFd(rs.pipe.getInputRemoteFd()); + } } var process = builder.start(); - if (uart != null) - uart.closeRemoteFd(); + for (var rs : resolvedSerials) + if (rs.pipe != null) rs.pipe.closeRemoteFd(); result.setProcess(process); stdoutStream.setInputStream(process.getInputStream()); stderrStream.setInputStream(process.getErrorStream()); } catch (IOException e) { Log.e(TAG, "Failed to start crosvm process", e); - if (uart != null) { - uart.close(); - uart = null; - } + closeSerialPorts(); controlSocketPath = null; inputBridge.release(); return result; @@ -135,6 +209,89 @@ public VMStartResult start() { return result; } + /** + * Turns the config's serial list into live host resources for this run: a daemon pipe pair + * per app-console port. A port whose pipes cannot be opened degrades to a sink in + * buildSerialCommand rather than failing the start. + */ + private void resolveSerialPorts() throws IOException { + closeSerialPorts(); + for (var port : VMSerialConfig.listOf(config.item)) { + var rs = new ResolvedSerial(port); + var backend = port.getBackend(); + if (backend == SerialBackend.APP_CONSOLE) { + var stream = serialStreams.get(port.getStreamName()); + if (stream != null) { + try { + var pipe = new SerialPipe(stream, port.getStreamName()); + if (pipe.isReady()) { + rs.pipe = pipe; + } else { + Log.w(TAG, fmt("Serial pipe %s not ready, discarding", + port.getStreamName())); + pipe.close(); + } + } catch (Exception e) { + Log.w(TAG, fmt("Failed to create serial pipe %s", + port.getStreamName()), e); + } + } + } else if (backend == SerialBackend.USB_ACM) { + // Attaches the configured slot of the daemon-wide ACM pool; only the pool's + // first-time build rebinds USB. A busy or out-of-range slot aborts the start + // (SlotUnavailableException propagates): a VM silently landing on another + // host COM port -- or stealing one -- is worse than not booting. Only a + // broken gadget degrades to a sink. + try { + rs.acmDevPath = UsbAcmPool.acquire(port.getUsbSlot(), + acmOwnerToken(port), context.appConfig); + } catch (UsbAcmPool.SlotUnavailableException e) { + throw e; + } catch (IOException e) { + Log.w(TAG, fmt("USB ACM for %s unavailable; port degrades to sink", + port.getStreamName()), e); + } + } + resolvedSerials.add(rs); + } + } + + @NonNull + private String acmOwnerToken(@NonNull VMSerialConfig port) { + return fmt("%s/%s", config.getId(), port.getStreamName()); + } + + private void closeSerialPorts() { + for (var rs : resolvedSerials) { + if (rs.pipe != null) rs.pipe.close(); + if (rs.acmDevPath != null) UsbAcmPool.release(acmOwnerToken(rs.port)); + } + resolvedSerials.clear(); + } + + /** + * Appends the guest-owned VRAM pool settings. Older configs only have the total pool size; + * their defaults keep the whole pool preallocated and leave dynamic grants disabled. The + * editor writes exactly those values (prealloc = pool, step 0, no grants) whenever its + * dynamic-vram switch is off over a guest pool, so a non-zero step here means the user + * asked for runtime growth. Only called under Gunyah. + */ + private static void appendGuestPoolOptions( + @NonNull StringBuilder preAlloc, + @NonNull DataItem item, + long guestPool + ) { + if (guestPool <= 0) return; + long step = item.optLong("gpu_guest_step_mb", 0); + if (preAlloc.length() > 0) preAlloc.append(','); + preAlloc.append(fmt("gpu-guest-mb=%d", guestPool)); + preAlloc.append(fmt(",gpu-guest-prealloc-mb=%d", + item.optLong("gpu_guest_prealloc_mb", guestPool))); + preAlloc.append(fmt(",gpu-guest-step-mb=%d", step)); + preAlloc.append(fmt(",gpu-guest-max-grants=%d", + item.optLong("gpu_guest_max_grants", 0))); + } + @NonNull private List buildCommand() { var item = config.item; @@ -151,10 +308,10 @@ private List buildCommand() { args.add(String.valueOf(Math.max(item.optLong("memory_mb", 512), 64))); args.add("--cpus"); args.add(String.valueOf(Math.max(item.optLong("cpu_count", 1), 1))); + buildCpuPlacementCommand(args); var hyp = item.optString("hypervisor", "auto"); var hypervisor = VMHypervisor.valueOf(hyp.toUpperCase()); - if (hypervisor == VMHypervisor.AUTO) - hypervisor = VMHypervisor.findPreferredHypervisor(VMBackend.CROSVM); + hypervisor = VMHypervisor.resolveConfigured(VMBackend.CROSVM, hypervisor); if (hypervisor == null) throw new RuntimeException("No supported hypervisor found for CROSVM backend"); args.add("--hypervisor"); var defProtectedMode = ProtectedVM.PROTECTED_NORMAL; @@ -162,22 +319,103 @@ private List buildCommand() { case KVM: args.add("kvm"); break; - case GUNYAH: + case GUNYAH: { + boolean hasGpu = VMScreenConfig.hasGpuDevice(item); + boolean gfxstreamGpu = hasGpu + && optEnum(item, "gpu_backend", GpuBackend.NONE) == GpuBackend.GPU_GFXSTREAM; + boolean drm2kgslGpu = hasGpu + && optEnum(item, "gpu_backend", GpuBackend.NONE) == GpuBackend.GPU_VIRGLRENDERER + && effectiveGpuMode(item) == GpuMode.NATIVE; + boolean venusGpu = hasGpu + && optEnum(item, "gpu_backend", GpuBackend.NONE) == GpuBackend.GPU_VIRGLRENDERER + && effectiveGpuMode(item) == GpuMode.VULKAN; + // Dynamic mappings keep the normal RegisterMemory interface. The Gunyah backend + // transparently supplies the protected-VM SHARE/accept transport. args.add("gunyah"); + // 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, and virtio-gpu with no + // pool node to find allocates from system RAM instead, which the host can read + // for the same reason. The editor hides the field in those modes; a config from + // the daemon API, or one saved before switching mode, still arrives with a size + // in it, so it is zeroed here rather than trusted. + // GuestPoolSizing holds that rule, shared with the huge-page preflight so the + // reserve is budgeted for exactly what is passed here. + long guestPool = GuestPoolSizing.bootGuestPoolMb(item); + // Pre-allocate the gfxstream host-visible pools (host arena + optional guest-alloc + // pool). Only meaningful for gfxstream on Gunyah. + if (gfxstreamGpu) { + boolean udmabuf = item.optBoolean("gpu_udmabuf", true); + long hostPool = item.optLong("gpu_host_pool_mb", 0); + if (hostPool > 0 || udmabuf) { + var preAlloc = new StringBuilder(fmt("gfx-host-mb=%d", hostPool)); + if (udmabuf) + appendGuestPoolOptions(preAlloc, item, guestPool); + args.add("--pre-alloc"); + args.add(preAlloc.toString()); + } + } + // DRM native context. Two pools, and they hold different things: + // drm-host-mb the host arena, now only the per-context msm shmem rings, so + // single-digit MB rather than the gigabyte the BOs used to need. + // gpu-guest-mb the guest's drm_buddy pool, where every BO comes from. Same + // region and flag as the gfxstream guest pool -- the guest driver + // keeps one allocator and cannot tell the renderers apart. + // The guest pool needs udmabuf=true as well; that is what gates + // VIRTIO_GPU_F_CREATE_GUEST_HANDLE, and without it guest mesa silently keeps a + // host-allocating path this host no longer implements. + if (drm2kgslGpu) { + long drmHostPool = item.optLong("gpu_drm2kgsl_pool_mb", 0); + var preAlloc = new StringBuilder(); + if (drmHostPool > 0) + preAlloc.append(fmt("drm-host-mb=%d", drmHostPool)); + appendGuestPoolOptions(preAlloc, item, guestPool); + if (preAlloc.length() > 0) { + args.add("--pre-alloc"); + args.add(preAlloc.toString()); + } + } + // Venus host pool: the venus command-stream transport shmems (per-instance ring + + // CS/reply chunks) live here (venus-host-mb -> VenusPool). vkr sub-allocates every + // blob_id==0 shmem from this pre-shared region and the guest maps pool_base+offset + // with no runtime SHARE. venus's real VkDeviceMemory is separately guest-alloc and + // comes from the shared guest pool (gpu-guest-mb) -- the same region/flag drm2kgsl + // and gfxstream guest-alloc use; the guest driver keeps one allocator and cannot + // tell the renderers apart. Default sized for the KDE+vkmark transport peak (cs + // pool alone is >=8M/instance): too small forces a per-blob memfd fallback -> + // runtime SHARE, which SoC-resets the fragile sm8650 (8gen3) RM. + if (venusGpu) { + long venusHostPool = item.optLong("gpu_venus_pool_mb", 256); + var preAlloc = new StringBuilder(); + if (venusHostPool > 0) + preAlloc.append(fmt("venus-host-mb=%d", venusHostPool)); + appendGuestPoolOptions(preAlloc, item, guestPool); + if (preAlloc.length() > 0) { + args.add("--pre-alloc"); + args.add(preAlloc.toString()); + } + } defProtectedMode = ProtectedVM.PROTECTED_WITHOUT_FIRMWARE; break; + } case GENIEZONE: args.add("geniezone"); break; default:throw new IllegalArgumentException(fmt("Unsupported hypervisor: %s", hypervisor)); } - switch (optEnum(item, "protected_vm", defProtectedMode)) { + var protectedVm = optEnum(item, "protected_vm", defProtectedMode); + switch (protectedVm) { case PROTECTED_PROTECTED: args.add("--protected-vm"); break; case PROTECTED_WITHOUT_FIRMWARE: args.add("--protected-vm-without-firmware"); break; + case PSEUDO_UNPROTECTED: + args.add("--protected-vm-pseudo-unprotected"); + break; default: break; } @@ -207,6 +445,18 @@ private List buildCommand() { default: break; } + var swiotlbMb = item.optLong("swiotlb_mb", 0); + // A pseudo-unprotected VM has nothing to bounce through -- its RAM is shared to it, so the + // host can already reach every buffer the guest hands a device. A pool here would do only + // harm: it puts a restricted-dma-pool node in the tree of a guest that was never built to + // honour one, which is the exact thing this mode exists to avoid. Ignore the stored value + // rather than asking everyone who switches to this mode to zero it by hand. + if (protectedVm == ProtectedVM.PSEUDO_UNPROTECTED) + swiotlbMb = 0; + if (swiotlbMb > 0) { + args.add("--swiotlb"); + args.add(String.valueOf(swiotlbMb)); + } var boot = BootPlan.of(config); if (!boot.initrd.isEmpty()) { args.add("--initrd"); @@ -220,17 +470,31 @@ private List buildCommand() { args.add("--socket"); args.add(controlSocketPath); } + // Real host CPU name for the guest: crosvm forwards it via FDT /chosen and EDK2 publishes + // it as SMBIOS Type 4 processor version, so UEFI guests (Windows) show e.g. + // "Qualcomm Snapdragon 8 Elite" instead of the firmware default "Gunyah vCPU". + var socName = HostSocName.get(); + if (socName != null) { + args.add("--smbios"); + args.add(fmt("processor-version=%s", socName)); + } buildDiskCommand(args); buildNetCommand(args); buildSharedDirCommand(args); buildGpuCommand(args); - buildVncCommand(args); + buildScreenExportersCommand(args); + // The evdev --input devices ride along whenever any app display path is active: the native + // display routes everything through them; the VNC display routes its MOUSE (relative) and + // TOUCH (multi-touch) modes here while the tablet pointer + keyboard stay on RFB. + if (isInputBridgeNeeded()) { + buildInputDevicesCommand(args); + } + buildPeripheralCommand(args); buildSerialCommand(args); item.opt("extra_options", DataItem.newArray()) .forEach(arg -> args.add(arg.getValue().asString())); if (boot.uefi) { - // crosvm has no custom-firmware support; always builtin EDK2 - args.add(PATH_EDK2_FIRMWARE); + args.add(boot.firmware.isEmpty() ? PATH_EDK2_FIRMWARE : boot.firmware); if (boot.varsEnabled) { var vars = boot.vars.isEmpty() ? PATH_EDK2_VARS : boot.vars; args.add("--pflash"); @@ -249,6 +513,83 @@ private static long pflashBlockSize(@NonNull String path) { return 262144; } + /** + * Creates and configures the gpuworker cpuset cgroup before crosvm starts. + * crosvm opens {@code /tasks} without creating the directory; the cpuset + * requires non-empty {@code cpus}/{@code mems} before threads can join it. + * + *

    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 args) { + CpuPlacementPlan.of(config.item).appendArgs(args); + if (gpuCgroupPath != null) { + args.add("--gpu-cgroup-path"); + args.add(gpuCgroupPath); + } + } + private void buildDiskCommand(@NonNull List args) { var disks = config.item.opt("disks", null); if (disks == null) return; @@ -315,138 +656,915 @@ private void buildSharedDirCommand(@NonNull List args) { var path = dir.optString("path", ""); var tag = dir.optString("tag", ""); if (path.isEmpty() || tag.isEmpty()) continue; - var type = optEnum(dir, "type", SharedDirType.FS); + // Only virtio-fs is wired up. The editor forces it, but a hand-edited vms.json can + // still say p9 -- and that is not a degraded mode, it is a VM that will not start: + // crosvm's 9p config accepts `ascii_casefold` and nothing else, so every key below + // makes the whole `--shared-dir` argument fail to parse. + if (optEnum(dir, "type", SharedDirType.FS) != SharedDirType.FS) + Log.w(TAG, fmt("Shared dir '%s': 9P is not implemented, using virtio-fs", tag)); + // `dax` is deliberately absent: the fs device gates DAX on cfg!(target_arch = + // "x86_64"), so on this platform the key would only describe something that cannot + // happen. var cache = optEnum(dir, "cache", SharedDirCache.AUTO); - args.add("--shared-dir"); - args.add(fmt( - "%s:%s:type=%s:cache=%s:timeout=%d:writeback=%s:dax=%s:posix_acl=%s", + var arg = new StringBuilder(fmt( + "%s:%s:type=fs:cache=%s:timeout=%d:writeback=%s:posix_acl=%s", path, tag, - type.name().toLowerCase(), cache.name().toLowerCase(), dir.optLong("timeout", 5), dir.optBoolean("writeback", false), - dir.optBoolean("dax", false), dir.optBoolean("posix_acl", true) )); + // Root access off -- the default -- means the file server serves as the app rather + // than as the VMM. crosvm forks and pivot_roots this device either way; these keys + // only decide who it is once it gets there. Left as root it reaches every file root + // can, which under /storage/emulated/0 is every other app's data as well. + if (!dir.optBoolean("root_access", false)) { + int uid = getAppUid(); + var gids = uid > 0 ? AppGroups.resolve(uid) : null; + if (gids == null) { + // Quietly serving as root instead would make the switch mean its opposite. + // That is the one outcome worth losing a shared directory over. + Log.e(TAG, fmt( + "Shared dir '%s': cannot resolve the app identity (uid=%d); skipped. " + + "Open DroidVM once, or turn on root access for this directory.", + tag, uid + )); + continue; + } + // An Android app's primary group is its uid. Say so rather than leaving gid + // unset, which would leave the process in root's group with the app's uid. + arg.append(fmt(":uid=%d:gid=%d", uid, uid)); + if (gids.length > 0) + arg.append(fmt(":supp_gids=%s", AppGroups.join(gids))); + } + args.add("--shared-dir"); + args.add(arg.toString()); } } + // Resolve the effective hypervisor (mirrors buildCommand's --hypervisor logic) to gate + // Gunyah-only GPU behavior such as gunyah-pvm. + private boolean isGunyahHypervisor() { + var hyp = config.item.optString("hypervisor", "auto"); + var hypervisor = VMHypervisor.valueOf(hyp.toUpperCase()); + hypervisor = VMHypervisor.resolveConfigured(VMBackend.CROSVM, hypervisor); + return hypervisor == VMHypervisor.GUNYAH; + } + + /** + * What the guest hands to the host: the {@code gpu_mode} row of the editor's three-level + * GPU section (renderer / mode / provider). + * + *

    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.

    + */ private void buildGpuCommand(@NonNull List args) { var item = config.item; - var useGpu = item.optBoolean("gpu_enabled", false); - var useDisplay = item.optBoolean("display_enabled", false); - var backend = optEnum(item, "display_backend", DisplayBackend.NONE); + var gpuScreen = isScreenEnabled(VMScreenConfig.ID_GPU0); + var fbScreen = isScreenEnabled(VMScreenConfig.ID_SIMPLEFB); var api = optEnum(item, "gpu_api", GpuApi.NONE); - if (!useGpu && !useDisplay) return; - if (useGpu) { + if (!gpuScreen && !fbScreen) return; + if (gpuScreen) { + var gpu0 = VMScreenConfig.of(item, VMScreenConfig.ID_GPU0); var gpuBackend = optEnum(item, "gpu_backend", GpuBackend.NONE); + var isGfxstream = gpuBackend == GpuBackend.GPU_GFXSTREAM; var gpuArg = new StringBuilder(); gpuArg.append(gpuBackend.getName()); - if (useDisplay && backend == VIRTIO_GPU) { - gpuArg.append(fmt(",displays=[[mode=windowed[%d,%d]", - item.optLong("display_width", 1280), - item.optLong("display_height", 720))); - gpuArg.append(fmt(",refresh-rate=%d", - item.optLong("display_refresh_rate", 60))); - gpuArg.append(fmt(",dpi=[%d,%d]]]", - item.optLong("display_dpi_h", 160), - item.optLong("display_dpi_v", 160))); + // gfxstream host-visible Vulkan: the guest turnip (VK) + zink (GL-on-VK) + // stack needs the gfxstream-vulkan context type. + if (isGfxstream) { + gpuArg.append(",context-types=gfxstream-vulkan"); } + // This screen's own geometry, unconditionally: the device and its scanout are emitted + // together or not at all. What the guest is told here is what it gets -- crosvm turns + // it into the EDID and the display-info a Linux guest picks its mode from -- and it no + // longer has anything to do with the simplefb screen, which now carries its own size + // to its own device below. + gpuArg.append(fmt(",displays=[[mode=windowed[%d,%d]", + gpu0.getWidth(), gpu0.getHeight())); + gpuArg.append(fmt(",refresh-rate=%d", gpu0.getRefreshRate())); + gpuArg.append(fmt(",dpi=[%d,%d]]]", gpu0.getDpiH(), gpu0.getDpiV())); - gpuArg.append(fmt(",vulkan=%s", String.valueOf(api == VULKAN))); - switch (api) { - case EGL: - gpuArg.append(",egl=true"); - break; - case OPENGLES: - gpuArg.append(",gles=true"); - break; - case ANGLE: - gpuArg.append(",angle=true"); - break; + if (isGfxstream) { + // gfxstream serves both VK (turnip) and GL (zink) clients, so both + // capsets are on. pci-bar-size is the host-visible BAR window and + // doubles as the GPU memory ceiling (the guest has no + // device-local-only memory type); default 4 GiB. + gpuArg.append(",vulkan=true,gles=true"); + gpuArg.append(fmt(",pci-bar-size=%d", + item.optLong("gpu_pci_bar_size", 0x100000000L))); + // Dynamic vram: a defined vram-limit is what enables runtime-shared host-visible + // memory (and, with a pre-alloc pool, fusion routing); leaving it undefined keeps + // every allocation inside the pool. crosvm ignores it in guest-alloc mode, where + // the guest pool is the cap, so only send it for host-alloc. + boolean udmabufGpu = item.optBoolean("gpu_udmabuf", true); + boolean dynamicVram = !udmabufGpu && item.optBoolean("gpu_dynamic_vram", false); + if (dynamicVram) { + // vram-limit supplies gfxstream's folio quota, the VK_EXT_memory_budget + // capacity handed to the guest driver, and -- by being defined and non-zero + // at all -- enables fusion routing. All are ignored under guest-alloc. + gpuArg.append( + fmt(",vram-limit=%d", item.optLong("gpu_vram_quota_mb", 2048))); + // gfxstream allocation policy: only its fresh host-visible shmem is eligible + // for folio backing. Driver-exported DMA-BUFs remain untouched. + gpuArg.append(fmt(",vram-folio-threshold-kb=%d", + item.optLong("gpu_vram_folio_threshold_kb", 1024))); + // Fusion size gate: host-visible allocations up to this try the pre-alloc + // pool first, larger ones go straight to the runtime-SHARE path. + gpuArg.append(fmt(",pool-blob-max-kb=%d", + item.optLong("gpu_pool_blob_max_kb", 4096))); + } + // gunyah-pvm pins the RingBlob backing so the permanent Gunyah SHARE mapping + // stays stable. Only meaningful under the Gunyah hypervisor; other SoCs skip it. + if (isGunyahHypervisor()) { + gpuArg.append(",gunyah-pvm=true"); + } + // Guest-allocated blobs: the guest owns the host-visible pool and hands + // dma-bufs to gfxstream via udmabuf, instead of the host growing the arena. + if (udmabufGpu) { + gpuArg.append(",udmabuf=true"); + } + } else if (effectiveGpuMode(item) == GpuMode.NATIVE) { + // DRM native context: the guest runs its own turnip over vdrm and virglrenderer + // translates the msm protocol to KGSL ioctls, so nothing is remoted at the + // GL/VK level and the host exposes no Vulkan capset. + // + // Only the DRM capset is advertised. rutabaga now keeps vrend (classic 2D + // resources: fbcon, dumb buffers, llvmpipe scanout) initialised for every + // virglrenderer configuration, so "drm" alone no longer fails the first + // CREATE_2D. Not advertising VIRGL2 matters for a stock guest: with it, stock + // Mesa picks host-GL virgl and our CPU-copy scanout cannot read those frames + // back (black until the guest additions + mesa-guest-drm2kgsl are installed); + // without it stock Mesa falls back to llvmpipe, which displays fine. + gpuArg.append(",context-types=drm"); + // No external-blob: create_gpu_device overwrites it with + // `is_sandboxed || fixed_blob_mapping` regardless of what the CLI said, so + // passing it would only suggest it does something. + gpuArg.append(",vulkan=false,egl=true,gles=true"); + // udmabuf builds the dma-buf for a guest-allocated blob, and -- less obviously -- + // it is what gates VIRTIO_GPU_F_CREATE_GUEST_HANDLE. Without it the feature is + // never offered, the guest reports has_create_guest_handle=0, and guest mesa + // falls back to a host-allocating path this host no longer implements. The VM + // boots and the desktop comes up; the failure waits for the first large buffer. + gpuArg.append(",udmabuf=true"); + gpuArg.append(fmt(",pci-bar-size=%d", + item.optLong("gpu_pci_bar_size", 0x100000000L))); + } else if (effectiveGpuMode(item) == GpuMode.VULKAN) { + // Venus: virglrenderer's Vulkan proxy (capset venus, guest-allocated blobs). + // Only the venus capset is advertised (see the drm branch above: rutabaga keeps + // vrend initialised for the classic 2D path regardless, and not advertising + // VIRGL2 keeps a stock guest on llvmpipe instead of an unreadable host-GL + // virgl). vulkan=true maps to use_venus on the virgl path; the venus capset also + // forces use_venus and use_guest_vram host-side, so this is belt-and-suspenders. + gpuArg.append(",context-types=venus"); + gpuArg.append(",vulkan=true,egl=true,gles=true"); + // udmabuf gates VIRTIO_GPU_F_CREATE_GUEST_HANDLE, the guest-alloc blob path venus + // uses (guest owns the pool, hands the host dma-bufs) -- same contract as drm2kgsl. + // No external-blob: create_gpu_device forces it from is_sandboxed||fixed_blob_mapping + // (false under --disable-sandbox), so passing the CLI key would be inert. + gpuArg.append(",udmabuf=true"); + gpuArg.append(fmt(",pci-bar-size=%d", + item.optLong("gpu_pci_bar_size", 0x100000000L))); + } else { + gpuArg.append(fmt(",vulkan=%s", String.valueOf(api == VULKAN))); + switch (api) { + case EGL: + gpuArg.append(",egl=true"); + break; + case OPENGLES: + gpuArg.append(",gles=true"); + break; + case ANGLE: + // crosvm has no `angle` --gpu key and rejects unknown ones outright, so + // emitting it would stop the VM from starting. Treat a config that + // still carries ANGLE as GLES, which is what the editor migrates it to. + gpuArg.append(",gles=true"); + break; + } } args.add("--gpu"); args.add(gpuArg.toString()); } - if (useDisplay && backend == SIMPLEFB) { + // The simplefb device is its own screen, so it rides on its own switch and carries its own + // size -- which is no longer required to equal the GPU screen's, and usually should not be. + // + // poll-hz is this screen's whole answer to "how often is there a picture": nothing in the + // device announces a frame, the guest maps the region write-combining and no write traps, + // so the host's sampling rate is the frame rate. Sent explicitly rather than left to + // crosvm's default, because it is a value the user can see in the editor and a default + // that only one of the two sides knows is a value nobody can check. + if (fbScreen) { + var fb = VMScreenConfig.of(item, VMScreenConfig.ID_SIMPLEFB); args.add("--simplefb"); args.add(fmt( - "width=%d,height=%d", - item.optLong("display_width", 1280), - item.optLong("display_height", 720) + "width=%d,height=%d,poll-hz=%d", + fb.getWidth(), fb.getHeight(), fb.getPollHz() )); } - // Native display: crosvm registers an ICrosvmAndroidDisplayService binder under a per-VM - // name and renders the gfxstream/virtio-gpu output straight into the Android Surface the UI - // hands it. Requires the GPU (virtio-gpu) path above. Touch/keyboard come back over the - // per-VM unix sockets the root service listens on; their paths must match NativeDisplay. - // Single source of truth for the enable check; isNativeDisplayEnabled() also gates the - // socket pre-bind in start(), so the two must never diverge. - if (isNativeDisplayEnabled()) { - buildNativeDisplayCommand(args); + } + + /** + * One exporter per screen: {@code --android-display-service} or {@code --vnc-server}, each + * naming the screen it is bound to. + * + *

    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.

    + */ + private void buildScreenExportersCommand(@NonNull List args) { + for (var screen : VMScreenConfig.listOf(config.item)) { + if (!isScreenEnabled(screen.id)) continue; + switch (screen.getExporter()) { + case NATIVE: + args.add("--android-display-service"); + args.add(fmt("name=%s,screen=%s%s", + NativeDisplay.serviceName(config, screen.id), screen.id, + transportCapArg(screen))); + break; + case VNC: + args.add("--vnc-server"); + args.add(buildVncArg(screen) + transportCapArg(screen)); + break; + default: + // A screen nobody is watching. Legal, and not the same thing as no screen. + break; + } } } - private void buildNativeDisplayCommand(@NonNull List args) { - var item = config.item; - var serviceName = NativeDisplay.serviceName(config); - var width = item.optLong("display_width", 1280); - var height = item.optLong("display_height", 720); - args.add("--android-display-service"); - args.add(serviceName); - // multi-touch ABS range must equal the guest resolution so view coords scale straight onto - // ABS_X/ABS_Y (see EvdevEncoder / TouchScaleCalculator). - args.add("--input"); - args.add(fmt( - "multi-touch[path=%s,width=%d,height=%d]", - NativeDisplay.inputSocketPath(serviceName, NativeDisplay.MULTITOUCH), width, height - )); + /** + * The ceiling on this binding's transport, as a key-value fragment for its exporter flag -- + * or nothing at all, which is what a default configuration gets. + * + *

    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.

    + */ + @NonNull + private static String transportCapArg(@NonNull VMScreenConfig screen) { + var token = DisplayTransportCap.emittedToken( + screen.id, screen.getExporter(), screen.getTransportCap()); + return token == null ? "" : fmt(",transport-cap=%s", token); + } + + // The virtio-input devices the UI drives; the daemon pre-binds the matching sockets (see + // start()) and the UI ships evdev records to them via vm_input / the direct sink. + // + // One of them is the VM's and three are each screen's. Only the relative pointer has no output + // binding at all -- the guest compositor routes it by focus and it walks from one output to + // the next -- so there is one of it for the VM. An absolute coordinate is only meaningful + // against one output's geometry, so multi-touch and the absolute pointer exist once per screen + // that has them, and each carries a name= built from the screen id: evdev has no "I belong to + // output N" field, so the guest is told which touchscreen is which output by hand, keyed on + // that name, in every guest OS. That is why the name is derived and never allocated -- it has + // to come back identical after a reboot or the user's mapping quietly stops matching. The + // keyboard is per screen on different grounds: input belongs to the scanout, so the screen's + // switch has to be able to turn typing off on that screen and only that screen. + // + // The three per-screen devices do not cover the same screens. The touchscreen is ours on + // either exporter, because multi-touch has no RFB representation -- the app synthesises it + // and injects it into that screen's socket while its console is up. The absolute pointer and + // the keyboard are ours only where the screen is exported natively: crosvm builds a + // VNC-exported screen's pair itself, behind that screen's own VNC server, and drives them from + // that server's RFB pointer and key events (see buildVncArg). Emitting either here too would + // hand the guest two devices under one evdev name, and the socket one would be the half + // nothing ever writes to. + private void buildInputDevicesCommand(@NonNull List args) { + var vmId = config.getId().toString(); + // No screen for this one, and the empty string says exactly that: its socket name does not + // take one, so there is no screen a console could name that would move it. + // + // Relative-pointer mouse (REL_X/Y + buttons + wheel) for InputMode.MOUSE; the guest renders + // the cursor, which is what relative-motion consumers (FPS games) need. args.add("--input"); args.add(fmt( - "keyboard[path=%s]", - NativeDisplay.inputSocketPath(serviceName, NativeDisplay.KEYBOARD) + "mouse[path=%s]", + NativeDisplay.inputSocketPath(vmId, "", NativeDisplay.MOUSE) )); + // multi-touch + absolute-mouse advertise a fixed normalized ABS range (crosvm + // NORMALIZED_ABS_MAX) because width/height are OMITTED here; the UI scales view coords to + // that range (EvdevEncoder.NORMALIZED_ABS_MAX / TouchScaleCalculator), so the mapping is + // resolution-independent and survives guest auto-resize -- no display size needed. + // + // crosvm's VM-global RFB device set is gone: --vnc-server no longer turns on + // display_window_mouse and no longer creates the "DroidVM VNC Touch"/Tablet/Mouse trio or + // the display-window keyboard. Those were one set for the whole VM, so with two VNC + // screens up both normalised into the same tablet and the guest had no way to tell which + // output a coordinate came from; and the set only ever reached the guest at all when no + // GPU device was configured. Each VNC binding now carries its own pair instead. + var nativeInputs = nativeInputScreens(); + for (var screenId : touchscreenScreens()) { + args.add("--input"); + args.add(fmt( + "multi-touch[path=%s,name=%s]", + NativeDisplay.inputSocketPath(vmId, screenId, NativeDisplay.MULTITOUCH), + NativeDisplay.touchDeviceName(screenId) + )); + // Tablet = crosvm's absolute-pointing mouse (qemu usb-tablet): ABS position + buttons + // + wheel, so it gives the guest pointer hover, right-click and scroll -- which + // single-touch (a BTN_TOUCH touchscreen) can't. The UI maps a host mouse/stylus onto + // it in TABLET mode. + // + // The tablet and the keyboard below are only for the natively exported screens; that + // list is a subset of the one being walked, so the gate sits inside the loop rather + // than beside it. + // + // It carries a name= for the same reason the touchscreen does. It could not before: + // crosvm's AbsoluteMouse option had no name field and its enum is deny_unknown_fields, + // so `absolute-mouse[...,name=X]` was not a device with an odd name but a command line + // crosvm refuses, and this screen's tablet fell back to the generated "Crosvm Virtio + // Absolute Mouse " -- an idx that counts emission order here and so moves when + // another screen's input is switched off. Both devices of the pair are pinnable now. + if (!nativeInputs.contains(screenId)) continue; + args.add("--input"); + args.add(fmt( + "absolute-mouse[path=%s,name=%s]", + NativeDisplay.inputSocketPath(vmId, screenId, NativeDisplay.TABLET), + NativeDisplay.tabletDeviceName(screenId) + )); + // This screen's keyboard. There is no VM-wide one any more: a keyboard shared by every + // screen could not be switched off by any one of them, so a screen with its input off + // still typed, which is not what the switch says. Now typing on a console goes to that + // console's screen's keyboard and a screen with input off has none -- and the guest + // sees several keyboards, which costs it nothing, because unlike an absolute device a + // keyboard needs no output binding to be routed correctly: focus decides. + args.add("--input"); + args.add(fmt( + "keyboard[path=%s,name=%s]", + NativeDisplay.inputSocketPath(vmId, screenId, NativeDisplay.KEYBOARD), + NativeDisplay.keyboardDeviceName(screenId) + )); + } + } + + /** + * The screens that get their own multi-touch device, in schema order: the screen exists, + * something is watching it, and its input switch is on. + * + *

    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.

    + */ + @NonNull + private List touchscreenScreens() { + var out = new ArrayList(); + for (var screen : VMScreenConfig.absoluteInputOf(config.item)) + out.add(screen.id); + return out; + } + + /** + * The screens whose absolute pointer and keyboard are ours to bind -- a subset of + * {@link #touchscreenScreens}: the natively exported ones. + * + *

    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.

    + */ + @NonNull + private List nativeInputScreens() { + var out = new ArrayList(); + for (var screen : VMScreenConfig.absoluteInputOf(config.item)) + if (screen.getExporter() == DisplayExporter.NATIVE) out.add(screen.id); + return out; } - /** True iff the per-VM crosvm command will reference native-display input sockets. */ - private boolean isNativeDisplayEnabled() { + // crosvm can promote the virtio-gpu worker to SCHED_FIFO (CROSVM_GPU_RT_PRIO). On gfxstream its + // per-context render threads inherit that policy and, spin-waiting on the guest command ring, + // can starve the normal-priority vCPU that feeds them -- a priority inversion that caps the + // native-display present rate. So RT is opt-in and off by default: the graphics tab's + // "real-time scheduling" switch (inside the GPU Worker Cpuset section) stores gpu_rt_prio + // ("97" on / "" off); pass it through only when set so crosvm leaves scheduling normal + // otherwise. + // + // Requires the cpuset: RT confined to the picked cores trades vCPU latency for render + // throughput on those cores, which is the point of the switch. RT with no cpuset is a + // different thing entirely -- FIFO 97 threads eligible for every host core, above everything + // else Android is running -- so it is refused rather than silently applied. Called after + // prepareGpuCgroup(), whose gpuCgroupPath is non-null only once the cpuset exists, holds + // cores and is about to be handed to crosvm. + private void applyGpuRtPrioEnv(@NonNull NativeProcess.Builder builder) { var item = config.item; - if (!item.optBoolean("gpu_enabled", false)) return false; - if (!item.optBoolean("display_enabled", false)) return false; - var backend = optEnum(item, "display_backend", DisplayBackend.NONE); - if (backend != DisplayBackend.VIRTIO_GPU) return false; - return item.optBoolean("native_display_enabled", false); + if (!VMScreenConfig.hasGpuDevice(item)) return; + // gpu_rt_prio is the SCHED_FIFO level as a string, "" (unset) by default. Empty means leave + // CROSVM_GPU_RT_PRIO unset so crosvm applies no real-time scheduling (RT is opt-in). + String prio = item.optString("gpu_rt_prio", ""); + if (prio.isEmpty()) return; + // The editor cannot save this combination; a config built straight through the daemon + // API can still carry it, as can one whose cpuset setup soft-failed (no root, bad path). + if (gpuCgroupPath == null) { + Log.w(TAG, "gpu_rt_prio set without a GPU worker cpuset; skipping real-time " + + "scheduling rather than leaving FIFO GPU threads free on every core"); + return; + } + builder.environment("CROSVM_GPU_RT_PRIO", prio); + } + + /** + * The host-Vulkan renderers (gfxstream, and venus on virglrenderer) need their host ICD + * selected, gfxstream its host-visible folio/blob env, and both a raised udmabuf import cap. + * No-op for OpenGL, Native and 2D. + */ + private void applyGfxstreamEnv(@NonNull NativeProcess.Builder builder) { + var item = config.item; + if (!VMScreenConfig.hasGpuDevice(item)) return; + var backend = optEnum(item, "gpu_backend", GpuBackend.NONE); + boolean gfxstream = backend == GpuBackend.GPU_GFXSTREAM; + // Venus is Vulkan-on-virglrenderer: it drives the host GPU through the same host ICD + // and the same guest-alloc udmabuf blobs as gfxstream, so it needs this env too. + boolean venus = backend == GpuBackend.GPU_VIRGLRENDERER + && effectiveGpuMode(item) == GpuMode.VULKAN; + if (!gfxstream && !venus) return; + // gfxstream-only: advertise a device-local memory type to the guest. The folio budget + // (vram-limit) and Gunyah RingBlob pin (gunyah-pvm) are on the --gpu line. + if (gfxstream) + builder.environment("GFXSTREAM_DEVICE_LOCAL_MEMORY_TYPE", "1"); + // Host Vulkan driver, for gfxstream and venus alike (both dlopen ANDROID_EMU_VK_LOADER_PATH + // ahead of the system loader: gfxstream's VulkanDispatch, venus's vkr_library). It follows + // gpu_api, which the editor derives from the provider row: VULKAN_SYSTEM / VULKAN_PANVK use + // the SoC's stock HAL (leave the env unset so the system loader picks the vendor ICD), + // anything else -- VULKAN_TURNIP, or plain VULKAN from a pre-provider config -- the + // bundled turnip. Either way the env falls back to the system HAL if the turnip file is + // missing. + var api = optEnum(item, "gpu_api", GpuApi.NONE); + boolean systemDriver = api == GpuApi.VULKAN_SYSTEM || api == GpuApi.VULKAN_PANVK; + if (!systemDriver) { + var turnip = pathJoin(DATA_DIR, "usr", "lib", "libvulkan_freedreno.so"); + if (new File(turnip).exists()) { + builder.environment("ANDROID_EMU_VK_LOADER_PATH", turnip); + } + } + // udmabuf's default 64MB/handle cap chokes large blob imports; raise it so a + // whole host-visible allocation can be wrapped as one dma-buf. The glob covers + // both the in-tree driver (/sys/module/udmabuf) and the app-shipped fallback + // module for kernels without CONFIG_UDMABUF (/sys/module/udmabuf_gki_6.1 etc.). + RunUtils.run("for p in /sys/module/udmabuf*/parameters/size_limit_mb; do " + + "echo %d > \"$p\"; done 2>/dev/null || true", + item.optLong("gpu_udmabuf_limit_mb", 4096)); } - private void buildVncCommand(@NonNull List args) { + /** + * Host Vulkan provider for the GPU blit ({@link GpuBlitProvider}) -- the dmabuf-import path + * every sink that does not memcpy goes through. It belongs to no one screen and to no one + * exporter: the same driver imports the virtio-gpu scanout and the simplefb framebuffer, and + * it is dlopened by the native bridge to blit into a Surface and by the VNC sink to blit into + * a headless target of its own. Any of those needs this pointed somewhere before it can use + * the GPU. This is a separate axis from the render host driver ({@link #applyGfxstreamEnv}); + * the two can name the same turnip .so or differ. + * + *

    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.

    + */ + private boolean isInputBridgeNeeded() { + for (var screen : VMScreenConfig.listOf(config.item)) { + if (!isScreenEnabled(screen.id)) continue; + if (screen.getExporter() != DisplayExporter.NONE) return true; + } + return false; + } + + @NonNull + private static String buildVncArg(@NonNull VMScreenConfig screen) { var vncArg = new StringBuilder(); - var host = item.optString("vnc_host", ""); + var host = screen.getVncHost(); if (!host.isEmpty()) { vncArg.append("host="); vncArg.append(host); vncArg.append(","); } vncArg.append("port="); - vncArg.append(Math.max(item.optLong("vnc_port", -1), 1)); - var password = item.optString("vnc_password", ""); + vncArg.append(Math.max(screen.getVncPort(), 1)); + var password = screen.getVncPassword(); if (!password.isEmpty()) { vncArg.append(",password="); vncArg.append(password); } - args.add("--vnc-server"); - args.add(vncArg.toString()); + // No "h264-port=" here, and it must not come back: the hardware H.264 stream is served on + // the RFB port as encoding 50, so there is no second listener to place. crosvm now refuses + // to parse a command line that names the old key rather than ignoring it, which is what + // makes a mixed deploy fail loudly at start instead of running with a silently dropped + // flag. A config left over from before the change still carries the number; nothing reads + // it (see VMScreenConfig). + // This screen's input switch, spelt for the other side. false makes crosvm build one + // tablet and one keyboard for this binding and inject its RFB pointer/key events into + // them; true makes it build neither and drop both, which is the only way to say "watch, + // don't touch" now that the devices belong to the binding rather than to the VM. + // + // The devices are the binding's, so a coordinate is read against the geometry of the + // screen the client is actually looking at -- which is what the retired VM-global set + // could not do with two VNC screens up. Pointer semantics stay absolute-tablet for every + // client, third-party or the app's own console; the app's VNC console is an RFB client + // like any other for pointer and keys, and reaches around RFB only for its TOUCH mode + // (this screen's multi-touch socket) and its MOUSE mode (the VM's relative pointer). + // + // The old "input=tablet" key is not merely unused now: crosvm's VncConfig is + // deny_unknown_fields, so emitting it would be a command line it refuses rather than a + // flag it ignores. That is deliberate -- a half-updated pair fails at start. + vncArg.append(",view-only="); + vncArg.append(!screen.isInputEnabled()); + vncArg.append(",screen="); + vncArg.append(screen.id); + return vncArg.toString(); + } + + /** + * Attaches the VM's peripherals. One peripheral is one guest device. + * + *

    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.

    + */ + private void buildPeripheralCommand(@NonNull List args) { + var peripherals = VMPeripheralConfig.listOf(config.item); + int appUid = getAppUid(); + for (var peripheral : peripherals) { + var type = peripheral.getType(); + if (type != PeripheralType.VIRTIO_SOUND) { + Log.w(TAG, fmt("peripheral %s skipped: no host backend", type)); + continue; + } + if (appUid <= 0) { + Log.e(TAG, "cannot resolve app uid; sound device skipped"); + continue; + } + if (peripheral.getEndpoints().isEmpty()) { + // A card with no endpoints is a device the guest would enumerate and find + // nothing behind, which is worse than not offering it. + Log.w(TAG, "virtio-snd card has no endpoints; skipped"); + continue; + } + args.add("--virtio-snd"); + args.add(buildSoundConfig(peripheral, appUid)); + } + } + + /** + * The `--virtio-snd` configuration for one peripheral. + * + *

    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.

    + */ + @NonNull + private String buildSoundConfig(@NonNull VMPeripheralConfig peripheral, int appUid) { + var endpoints = peripheral.getEndpoints(); + var outputs = new ArrayList(); + var inputs = new ArrayList(); + for (var endpoint : endpoints) { + (endpoint.getMode().isInput() ? inputs : outputs).add(endpoint); + } + + // A diagnostic escape hatch: with this marker present the card writes the periods it + // receives to stream-N.out instead of playing them, which is the only way to see what + // actually crossed the virtqueue rather than what everyone reports having sent. Gated on + // a file rather than a build so it can be turned off without shipping anything. + var dump = new java.io.File("/data/local/tmp/viosnd_dump"); + var cfg = new StringBuilder(dump.exists() ? "backend=file" : "backend=aaudio"); + if (dump.exists()) { + cfg.append(fmt(",playback_path=%s,playback_size=%d", + "/data/data/cn.classfun.droidvm/cache", 4 * 1024 * 1024)); + } + // capture= is the card's own flag for whether it has any input at all. + cfg.append(fmt(",capture=%b", !inputs.isEmpty())); + cfg.append(fmt(",num_output_devices=%d", outputs.size())); + cfg.append(fmt(",num_input_devices=%d", inputs.size())); + // The endpoints go over by name, not by number. A number is only what the platform calls + // an endpoint today: reconnect a headset and it has a different one, while the name is + // unchanged -- so crosvm looks each one up in the table for itself, every time it opens a + // stream, and finds a returning device without being told. + cfg.append(fmt(",device_table=%s", HostAudioTable.PATH)); + appendEndpoints(cfg, "output_device_config", outputs); + appendEndpoints(cfg, "input_device_config", inputs); + // Shared by every endpoint on the card, because they describe the device's queues rather + // than any one endpoint: one underrun policy, one buffer depth. Separate cards keep their + // own. + cfg.append(fmt(",underrun=%s", peripheral.getUnderrun().name().toLowerCase())); + // The latency knob. crosvm publishes it in the device's vendor config block; a driver + // that does not read the block keeps its own default, which is why this can be a hint. + cfg.append(fmt(",guest_outstanding_packets=%d", peripheral.getBuffer().getPackets())); + // Same field name as --shared-dir and --pmem-ext2 use for the process a device runs as. + // No supplementary groups: the VMM's are root's, and audio needs none -- recording was + // measured working with an empty group list. + cfg.append(fmt(",uid=%d", appUid)); + Log.i(TAG, fmt("sound device: %s", cfg)); + return cfg.toString(); + } + + /** + * Appends one direction's endpoints as a list of per-device settings. + * + *

    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.

    + */ + private void appendEndpoints( + @NonNull StringBuilder cfg, @NonNull String field, + @NonNull List endpoints + ) { + if (endpoints.isEmpty()) return; + cfg.append(fmt(",%s=[", field)); + for (int i = 0; i < endpoints.size(); i++) { + var endpoint = endpoints.get(i); + // An unset field is what older configs stored for "follow the platform"; it names the + // same endpoint now, so everything downstream sees a device rather than an absence. + var hostKey = endpoint.getHostDevice(); + if (hostKey.isEmpty()) hostKey = HostAudioDevices.SYSTEM_DEFAULT_KEY; + // Quoted, because a key is TYPE|address and the bar would otherwise read as a + // separator to the option parser. + cfg.append(fmt("%s[host_device=\"%s\"]", i == 0 ? "" : ",", hostKey)); + // Only to say in the log which endpoint that name means right now. + resolveHostDevice(hostKey, endpoint.getMode().isInput(), endpoint.getMode()); + } + cfg.append("]"); + } + + /** The app's uid, which the daemon can reach through its system context. */ + private int getAppUid() { + try { + var sys = DaemonSystemContext.get(); + if (sys != null) { + return sys.getPackageManager() + .getApplicationInfo(BuildConfig.APPLICATION_ID, 0).uid; + } + } catch (Throwable t) { + Log.w(TAG, "package manager lookup failed; falling back to the data dir owner", t); + } + // Fallback: the data directory belongs to the app uid by construction. + try { + return android.system.Os.stat(DATA_DIR).st_uid; + } catch (Throwable t) { + Log.w(TAG, "stat of the data dir failed", t); + return -1; + } + } + + /** + * Live AAudio device id for a stored host endpoint, or 0 (AAUDIO_UNSPECIFIED) when it asked + * to follow the system or names something that is not connected right now. The ids are + * per-boot, which is why the config stores a descriptor and this happens at start. + */ + private int resolveHostDevice( + @NonNull String key, boolean input, @NonNull SoundMode mode + ) { + if (key.isEmpty()) return HostAudioDevices.DEVICE_UNSPECIFIED; + var sys = DaemonSystemContext.get(); + if (sys == null) { + Log.w(TAG, fmt("no system context; %s falls back to default audio routing", key)); + return HostAudioDevices.DEVICE_UNSPECIFIED; + } + int id = HostAudioDevices.resolve(sys, input, key); + Log.i(TAG, fmt("%s -> host device %s (id=%d)", mode, key, id)); + return id; + } + + + /** One port's crosvm argument pieces, resolved from its backend. */ + private static final class SerialArg { + String type = "sink"; + String path; + String input; + boolean interactive; + } + + @NonNull + private SerialArg serialArgOf(@NonNull ResolvedSerial rs) { + var arg = new SerialArg(); + var port = rs.port; + switch (port.getBackend()) { + case APP_CONSOLE: + if (rs.pipe != null) { + arg.type = "file"; + arg.path = fmt("/proc/self/fd/%d", rs.pipe.getOutputRemoteFd()); + arg.input = fmt("/proc/self/fd/%d", rs.pipe.getInputRemoteFd()); + arg.interactive = true; + } + break; + case PTY: + // crosvm's own pty type: it holds the master; the optional path becomes + // a symlink to the slave for external consumers. + arg.type = "pty"; + if (!port.getPath().isEmpty()) arg.path = port.getPath(); + arg.interactive = true; + break; + case FILE: + arg.type = "file"; + arg.path = port.getPath(); + break; + case UNIX: + arg.type = "unix"; + arg.path = port.getPath(); + break; + case UNIX_STREAM: + // crosvm is the connecting side: the consumer must already listen there. + arg.type = "unix-stream"; + arg.path = port.getPath(); + arg.interactive = true; + break; + case STDOUT: + arg.type = "stdout"; + break; + case SYSLOG: + arg.type = "syslog"; + break; + case USB_ACM: + // A pool member was attached in resolveSerialPorts; crosvm opens the + // ttyGSn raw and non-blocking (drops output when the external host is + // not draining, so the guest console never stalls). + if (rs.acmDevPath != null) { + arg.type = "dev"; + arg.path = rs.acmDevPath; + arg.interactive = true; + } + break; + case SINK: + default: + break; + } + return arg; } + /** + * One --serial per configured port. Exactly one port carries console+earlycon -- that is + * what crosvm's FDT stdout-path (and so EDK2's SPCR, and so Windows EMS/SAC) points at. + * The port the user marked as console wins, whatever its backend: a sink console is a + * deliberate "discard the guest console". Only configs from before the explicit flag + * fall back to the historical rule, the first port that can carry a conversation. + */ private void buildSerialCommand(@NonNull List args) { - if (uart == null) return; - var serial = fmt( - "type=file,hardware=serial,num=1,earlycon,console,path=/proc/self/fd/%d,input=/proc/self/fd/%d", - uart.getOutputRemoteFd(), uart.getInputRemoteFd() - ); - args.add("--serial"); - args.add(serial); + var serialArgs = new ArrayList(resolvedSerials.size()); + for (var rs : resolvedSerials) + serialArgs.add(serialArgOf(rs)); + var consoleIdx = -1; + for (int i = 0; i < resolvedSerials.size(); i++) + if (resolvedSerials.get(i).port.isConsole()) { + consoleIdx = i; + break; + } + if (consoleIdx < 0) + for (int i = 0; i < serialArgs.size(); i++) + if (serialArgs.get(i).interactive) { + consoleIdx = i; + break; + } + for (int i = 0; i < resolvedSerials.size(); i++) { + var port = resolvedSerials.get(i).port; + var arg = serialArgs.get(i); + var serial = new StringBuilder(fmt( + "type=%s,hardware=%s,num=%d", + arg.type, port.getHardware().getCrosvmName(), port.getNum() + )); + if (arg.path != null) serial.append(fmt(",path=%s", arg.path)); + if (arg.input != null) serial.append(fmt(",input=%s", arg.input)); + if (i == consoleIdx) { + serial.append(",console"); + // earlycon is a UART notion; a virtio-console has no early MMIO registers. + if (port.getHardware() != SerialHardware.VIRTIO_CONSOLE) + serial.append(",earlycon"); + } + args.add("--serial"); + args.add(serial.toString()); + } } @Nullable @@ -512,8 +1630,8 @@ public boolean hasControlSocket() { } @Override - public boolean writeNativeInput(int channel, @NonNull byte[] data) { - return inputBridge.writeNativeInput(channel, data); + public boolean writeNativeInput(@NonNull String screenId, int channel, @NonNull byte[] data) { + return inputBridge.writeNativeInput(screenId, channel, data); } @Override @@ -523,5 +1641,6 @@ public void cleanup() { controlSocketPath = null; } inputBridge.release(); + closeSerialPorts(); } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/HostSocName.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/HostSocName.java new file mode 100644 index 00000000..cc74f724 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/HostSocName.java @@ -0,0 +1,56 @@ +// 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.backend; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.util.Log; + +import androidx.annotation.Nullable; + +import cn.classfun.droidvm.BuildConfig; +import cn.classfun.droidvm.daemon.display.DaemonSystemContext; +import cn.classfun.droidvm.lib.data.QcomChipName; + +/** + * Resolves the host SoC marketing name (e.g. "Qualcomm Snapdragon 8 Elite") inside the daemon so + * VM backends can forward it to guest firmware ({@code crosvm --smbios processor-version=...} -> + * FDT /chosen -> EDK2 SMBIOS Type 4), letting Windows show the real CPU name instead of the + * firmware's built-in "Gunyah vCPU". + * + * The lookup table lives in the app's {@code res/xml/qcom.xml}; the daemon reaches it through a + * package context created from its system context. Falls back to the raw SoC model string + * (e.g. "SM8750P") when resources are unavailable, and to null when even getprop yields nothing. + */ +public final class HostSocName { + private static final String TAG = "HostSocName"; + private static String cached; + private static boolean resolved; + + private HostSocName() { + } + + @Nullable + public static synchronized String get() { + if (resolved) return cached; + resolved = true; + try { + var soc = QcomChipName.getCurrentSoC(); + if (soc == null || soc.trim().isEmpty()) return cached = null; + soc = soc.trim(); + cached = soc; + var sys = DaemonSystemContext.get(); + if (sys != null) { + var appCtx = sys.createPackageContext(BuildConfig.APPLICATION_ID, 0); + var name = new QcomChipName(appCtx).lookupChipName(soc); + if (name != null && !name.trim().isEmpty()) + cached = name.trim(); + } + Log.i(TAG, fmt("host SoC name: %s", cached)); + } catch (Throwable t) { + Log.w(TAG, "failed to resolve host SoC name", t); + } + return cached; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/NativeDisplayInputBridge.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/NativeDisplayInputBridge.java index af5a3c9b..5d4042c4 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/NativeDisplayInputBridge.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/NativeDisplayInputBridge.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.backend; import static cn.classfun.droidvm.lib.utils.FileUtils.deleteFile; @@ -6,91 +9,201 @@ import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.io.IOException; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import cn.classfun.droidvm.lib.natives.UnixHelper; import cn.classfun.droidvm.lib.network.FDSocket; import cn.classfun.droidvm.lib.store.vm.NativeDisplay; /** - * Owns the per-VM native-display input sockets for one crosvm instance. crosvm's --input + * Owns the native-display input sockets for one crosvm instance. crosvm's --input * [path=...] connects to a unix socket whose inode must already exist (crosvm is the * *client*), so the daemon is the only process that can both bind the socket before crosvm starts * and stay alive to feed it. This bridge pre-binds + accepts the crosvm-facing sockets; evdev from - * the UI arrives via {@link #writeNativeInput(int, byte[])} - called either on the daemon's + * the UI arrives via {@link #writeNativeInput(String, int, byte[])} - called either on the daemon's * native-display broker binder thread (touch hot path) or from the vm_input IPC handler - and is * written straight to the matching crosvm peer. {@link CrosvmBackendInstance} drives the lifecycle: - * {@link #startListening(String)} from start() and {@link #release()} from cleanup(). + * {@link #startListening(String, List, List)} from start() and {@link #release()} from cleanup(). + * + *

    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.

    */ final class NativeDisplayInputBridge { private static final String TAG = "NativeDisplayInput"; - /** Server fds for the per-VM native-display input sockets, kept while crosvm is running. */ - private volatile int[] inputServerFds = null; - /** Paths of the input sockets we listened on, so {@link #release()} can unlink them. */ - private volatile String[] inputSocketPaths = null; /** - * The crosvm-side connection accepted on each input channel. crosvm connects to OUR socket at - * startup (we are the only listener), so writing UI-forwarded evdev here is what actually - * reaches the guest. Indexed by NativeDisplay channel constants. Volatile because the accept - * threads, the write threads, and release() all touch it without a single shared lock. + * One crosvm-facing input socket: the inode we bound, the accepted crosvm connection, and the + * lock that keeps a write and a reconnect off each other. crosvm connects to OUR socket at + * startup (we are the only listener), so writing UI-forwarded evdev to {@link #peer} is what + * actually reaches the guest. */ - private volatile FDSocket[] inputPeers = null; - /** Per-channel write lock; also guards swapping {@link #inputPeers} on reconnect. */ - private volatile Object[] inputWriteLocks = null; + private static final class Slot { + final String key; + final String path; + final int serverFd; + final Object lock = new Object(); + /** Volatile: accept threads, write threads and release() all touch it without one lock. */ + volatile FDSocket peer; + + Slot(@NonNull String key, @NonNull String path, int serverFd) { + this.key = key; + this.path = path; + this.serverFd = serverFd; + } + } + + /** + * Live slots by {@link #slotKey}. Replaced wholesale on start and cleared on release, so a + * reader either sees the whole set or none of it. Null until the first start. + */ + private volatile Map slots = null; private volatile boolean inputClosed = false; /** - * Pre-creates the per-VM native-display input sockets as listening unix sockets. crosvm - * connects to these paths at startup, so a listener must exist before - * {@link CrosvmBackendInstance#start()} execs the crosvm process. nativeUnixListen unlinks any - * stale inode and re-binds, so a leftover socket file from a crashed run is replaced rather than - * blocking us. Returns true iff every channel ended up with a live listener. Server fds we open - * are tracked for release in {@link #release()}. + * Identity of one socket: the channel, plus the screen for the channels that have one per + * screen. The VM-wide channel collapses onto the empty screen so the same key comes out + * whatever screen the console that sent the bytes happens to be showing. + * + *

    {@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.

    */ - boolean startListening(@NonNull String serviceName) { + @NonNull + private static String slotKey(@NonNull String screenId, int channel) { + return fmt("%s/%d", NativeDisplay.isPerScreen(channel) ? screenId : "", channel); + } + + /** + * Pre-creates the input sockets as listening unix sockets. crosvm connects to these paths at + * startup, so a listener must exist before {@link CrosvmBackendInstance#start()} execs the + * crosvm process. nativeUnixListen unlinks any stale inode and re-binds, so a leftover socket + * file from a crashed run is replaced rather than blocking us. Returns true iff every slot + * ended up with a live listener. + * + *

    [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.

    + */ + boolean startListening(@NonNull String vmId, @NonNull List touchScreens, + @NonNull List nativeScreens) { if (!UnixHelper.isLoaded()) { Log.w(TAG, "UnixHelper not loaded; cannot pre-bind native-display input sockets"); return false; } - var paths = new String[NativeDisplay.CHANNEL_COUNT]; - var fds = new int[NativeDisplay.CHANNEL_COUNT]; inputClosed = false; - inputPeers = new FDSocket[NativeDisplay.CHANNEL_COUNT]; - inputWriteLocks = new Object[NativeDisplay.CHANNEL_COUNT]; + var built = new LinkedHashMap(); + boolean allListening; + try { + allListening = bindAll(vmId, touchScreens, nativeScreens, built); + } catch (RuntimeException e) { + closeSlots(built.values()); + throw e; + } + // Published whole, so a write either finds the set this start built or finds nothing. + slots = built; + return allListening; + } + + /** + * Binds every slot the config asks for into [built]. Returns false if any bind failed. + * + *

    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.

    + */ + private boolean bindAll(@NonNull String vmId, @NonNull List touchScreens, + @NonNull List nativeScreens, + @NonNull Map built) { boolean allListening = true; for (int ch = 0; ch < NativeDisplay.CHANNEL_COUNT; ch++) { - inputWriteLocks[ch] = new Object(); - var path = NativeDisplay.inputSocketPath(serviceName, ch); - paths[ch] = path; - var fd = UnixHelper.nativeUnixListen(path); - if (fd < 0) { - Log.w(TAG, fmt("Failed to pre-listen on input socket: %s", path)); - allListening = false; - fds[ch] = -1; - } else { + for (var screenId : screensFor(ch, touchScreens, nativeScreens)) { + // Before the syscall, not after: a path bind(2) cannot hold is truncated in + // silence and every check downstream then passes against the wrong inode. + var path = NativeDisplay.requireBindablePath( + NativeDisplay.inputSocketPath(vmId, screenId, ch)); + var fd = UnixHelper.nativeUnixListen(path); + if (fd < 0) { + Log.w(TAG, fmt("Failed to pre-listen on input socket: %s", path)); + allListening = false; + continue; + } Log.i(TAG, fmt("Pre-listening on input socket: %s (fd=%d)", path, fd)); - fds[ch] = fd; + var slot = new Slot(slotKey(screenId, ch), path, fd); + built.put(slot.key, slot); // Accept crosvm's connection in the background. crosvm is the client and connects // at its own startup, so a peer may not arrive until after start() execs it. - startInputAcceptThread(ch, fd); + startInputAcceptThread(slot); } } - inputSocketPaths = paths; - inputServerFds = fds; return allListening; } /** - * Accepts crosvm's connection on one input channel and keeps the live peer in - * {@link #inputPeers}. Loops so a crosvm restart (new connection on the same socket) replaces - * the dead peer; ends when {@link #release()} closes the server fd. + * The screens [channel] needs a socket for: its own list for the three per-screen channels, or + * just the VM itself for the relative pointer, which has no output binding. + * + *

    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.

    + */ + @NonNull + private static List screensFor(int channel, @NonNull List touchScreens, + @NonNull List nativeScreens) { + switch (channel) { + case NativeDisplay.MULTITOUCH: + return touchScreens; + case NativeDisplay.TABLET: + case NativeDisplay.KEYBOARD: + return nativeScreens; + default: + return List.of(""); + } + } + + /** + * Accepts crosvm's connection on one slot and keeps the live peer on it. Loops so a crosvm + * restart (new connection on the same socket) replaces the dead peer; ends when + * {@link #release()} closes the server fd. */ - private void startInputAcceptThread(int channel, int serverFd) { + private void startInputAcceptThread(@NonNull Slot slot) { var t = new Thread(() -> { while (!inputClosed) { - int peerFd = UnixHelper.nativeUnixAccept(serverFd); + int peerFd = UnixHelper.nativeUnixAccept(slot.serverFd); if (peerFd < 0) { if (inputClosed) break; try { @@ -101,46 +214,36 @@ private void startInputAcceptThread(int channel, int serverFd) { continue; } var peer = new FDSocket(peerFd); - // Snapshot the lock/peer arrays: release() can null them concurrently. - var locks = inputWriteLocks; - if (locks == null) { - peer.close(); - break; - } - synchronized (locks[channel]) { - var peers = inputPeers; - if (peers == null) { + synchronized (slot.lock) { + if (inputClosed) { peer.close(); break; } - var old = peers[channel]; - peers[channel] = peer; + var old = slot.peer; + slot.peer = peer; if (old != null) old.close(); } - Log.i(TAG, fmt("crosvm input connected: channel %d", channel)); + Log.i(TAG, fmt("crosvm input connected: %s", slot.path)); } - }, fmt("CrosvmInputAccept-%d", channel)); + }, fmt("CrosvmInputAccept-%s", slot.key)); t.setDaemon(true); t.start(); } /** - * Writes pre-encoded evdev bytes (8-byte records) to the crosvm connection for [channel]. - * Called from the daemon IPC thread on behalf of the UI. Returns false if no crosvm peer is - * connected yet or the write fails. + * Writes pre-encoded evdev bytes (8-byte records) to the crosvm connection for [channel] on + * [screenId]. Called from the daemon IPC thread or the broker binder thread on behalf of the + * UI. Returns false if the VM has no such device (a per-screen channel -- touch, tablet or + * keyboard -- on a screen whose input is switched off, or on a VNC-exported screen whose + * tablet and keyboard are crosvm's), no crosvm peer is connected yet, or the write fails -- + * the caller reports that as "not delivered" rather than pretending it landed somewhere. */ - boolean writeNativeInput(int channel, @NonNull byte[] data) { + boolean writeNativeInput(@NonNull String screenId, int channel, @NonNull byte[] data) { if (channel < 0 || channel >= NativeDisplay.CHANNEL_COUNT || data.length == 0) return false; - // Snapshot the arrays once: release() nulls these fields concurrently, so dereferencing the - // live field after the guard could NPE. The lock object itself stays valid for the session. - var locks = inputWriteLocks; - if (locks == null) return false; - var lock = locks[channel]; - if (lock == null) return false; - synchronized (lock) { - var peers = inputPeers; - if (peers == null) return false; - var peer = peers[channel]; + var slot = findSlot(screenId, channel); + if (slot == null) return false; + synchronized (slot.lock) { + var peer = slot.peer; if (peer == null || !peer.isOpen()) return false; try { var os = peer.getOutputStream(); @@ -148,47 +251,50 @@ boolean writeNativeInput(int channel, @NonNull byte[] data) { os.flush(); return true; } catch (IOException e) { - Log.w(TAG, fmt("input write channel %d failed: %s", channel, e.getMessage())); - peers[channel] = null; + Log.w(TAG, fmt("input write to %s failed: %s", slot.path, e.getMessage())); + slot.peer = null; peer.close(); return false; } } } + @Nullable + private Slot findSlot(@NonNull String screenId, int channel) { + // Snapshot the map once: release() nulls the field concurrently. + var live = slots; + return live == null ? null : live.get(slotKey(screenId, channel)); + } + + /** Closes the input server fds we opened and unlinks the inodes we own. */ + void release() { + var live = slots; + slots = null; + // Called unconditionally rather than under a null check, because latching inputClosed is + // half of what it does and a release with nothing published still has to do that half. + closeSlots(live == null ? List.of() : live.values()); + } + /** - * Closes the input server fds we opened and unlinks only the inodes we own. Channels that - * fell through to the UI's listener (fd == -1) are left untouched so we don't yank a socket - * the UI still holds. + * Gives back everything [live] holds: the accepted peer, the listening fd, and the inode. + * + *

    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.

    */ - void release() { - inputClosed = true; // stop accept loops; closing the server fd below unblocks nativeUnixAccept - if (inputPeers != null) { - for (int ch = 0; ch < inputPeers.length; ch++) { - if (inputWriteLocks != null && inputWriteLocks[ch] != null) { - synchronized (inputWriteLocks[ch]) { - if (inputPeers[ch] != null) { - inputPeers[ch].close(); - inputPeers[ch] = null; - } - } + private void closeSlots(@NonNull Collection live) { + inputClosed = true; + for (var slot : live) { + synchronized (slot.lock) { + if (slot.peer != null) { + slot.peer.close(); + slot.peer = null; } } - inputPeers = null; - } - inputWriteLocks = null; - if (inputServerFds == null) { - inputSocketPaths = null; - return; - } - for (int ch = 0; ch < inputServerFds.length; ch++) { - int fd = inputServerFds[ch]; - if (fd < 0) continue; - UnixHelper.nativeCloseFd(fd); - if (inputSocketPaths != null && inputSocketPaths[ch] != null) - deleteFile(inputSocketPaths[ch]); + UnixHelper.nativeCloseFd(slot.serverFd); + deleteFile(slot.path); } - inputServerFds = null; - inputSocketPaths = null; } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/QemuBackend.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/QemuBackend.java index 6479786d..4bb9fde0 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/QemuBackend.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/QemuBackend.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.backend; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/QemuBackendInstance.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/QemuBackendInstance.java index c8e33c79..80efe55e 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/QemuBackendInstance.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/backend/QemuBackendInstance.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.backend; import static android.net.LocalSocketAddress.Namespace.FILESYSTEM; @@ -37,13 +40,16 @@ import cn.classfun.droidvm.lib.natives.NativeProcess; import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.lib.store.disk.DiskBus; -import cn.classfun.droidvm.lib.store.vm.DisplayBackend; +import cn.classfun.droidvm.lib.store.vm.DisplayExporter; +import cn.classfun.droidvm.lib.store.vm.VMScreenConfig; import cn.classfun.droidvm.lib.store.vm.GpuBackend; +import cn.classfun.droidvm.lib.store.vm.PeripheralType; import cn.classfun.droidvm.lib.store.vm.ProtectedVM; import cn.classfun.droidvm.lib.store.vm.SharedDirCache; import cn.classfun.droidvm.lib.store.vm.VMBackend; import cn.classfun.droidvm.lib.store.vm.VMConfig; import cn.classfun.droidvm.lib.store.vm.VMHypervisor; +import cn.classfun.droidvm.lib.store.vm.VMPeripheralConfig; @SuppressWarnings("FieldCanBeLocal") public final class QemuBackendInstance extends VMBackendInstance { @@ -51,9 +57,13 @@ public final class QemuBackendInstance extends VMBackendInstance { private static final String RUN_PATH = pathJoin(DATA_DIR, "run"); private String qmpSocketPath = null; private String uartSocketPath = null; + private String agentSocketPath = null; private int ioThreadCounter = 0; private int driveCounter = 0; + private final boolean agentMode; private final LocalSocketConsoleStream uartStream; + @Nullable + private final LocalSocketConsoleStream agentStream; private final InputConsoleStream stdoutStream; private final InputConsoleStream stderrStream; private final SimpleConsoleStream stdioStream; @@ -63,7 +73,12 @@ public QemuBackendInstance( @NonNull VMConfig config ) { super(context, config); + agentMode = config.item.optBoolean("agent_mode", false); uartStream = new LocalSocketConsoleStream(config, "uart", null); + agentStream = agentMode + ? new LocalSocketConsoleStream(config, "agent", null) : null; + if (agentStream != null) + agentStream.setPersistentLogEnabled(false); stdoutStream = new InputConsoleStream(config, "stdout", null); stderrStream = new InputConsoleStream(config, "stderr", null); stdioStream = new SimpleConsoleStream(config, "stdio"); @@ -71,6 +86,7 @@ public QemuBackendInstance( addStream(stderrStream); addStream(stdioStream); addStream(uartStream); + if (agentStream != null) addStream(agentStream); } @NonNull @@ -83,6 +99,10 @@ public VMStartResult start() { deleteFile(qmpSocketPath); uartSocketPath = pathJoin(RUN_PATH, fmt("%s-uart.sock", config.getName())); deleteFile(uartSocketPath); + if (agentMode) { + agentSocketPath = pathJoin(RUN_PATH, fmt("%s-agent.sock", config.getName())); + deleteFile(agentSocketPath); + } Log.i(TAG, fmt("QMP socket path: %s", qmpSocketPath)); var args = buildCommand(); Log.i(TAG, fmt("Executing: %s", String.join(" ", args))); @@ -97,23 +117,32 @@ public VMStartResult start() { Log.e(TAG, "Failed to start qemu process", e); return result; } - waitForUartClient(); + // agent0 is declared before the blocking UART chardev. Connect it first, then release + // QEMU's UART wait so no hvc0 boot output can race ahead of the daemon reader. + if (agentStream != null) + waitForSocketClient("agent", agentSocketPath, agentStream); + waitForSocketClient("UART", uartSocketPath, uartStream); return result; } - private void waitForUartClient() { + private void waitForSocketClient( + @NonNull String label, + @NonNull String socketPath, + @NonNull LocalSocketConsoleStream stream + ) { int i = 0; - Log.i(TAG, fmt("UART socket path: %s", uartSocketPath)); + Log.i(TAG, fmt("%s socket path: %s", label, socketPath)); while (true) { try { - var uart = new LocalSocket(LocalSocket.SOCKET_STREAM); - uart.connect(new LocalSocketAddress(uartSocketPath, FILESYSTEM)); - Log.i(TAG, "UART client connected"); - uartStream.setSocket(uart); + var socket = new LocalSocket(LocalSocket.SOCKET_STREAM); + socket.connect(new LocalSocketAddress(socketPath, FILESYSTEM)); + Log.i(TAG, fmt("%s client connected", label)); + stream.setSocket(socket); return; } catch (Exception e) { if (i >= 50) { - Log.e(TAG, "failed to create UART socket after multiple attempts, giving up"); + Log.e(TAG, fmt( + "failed to connect %s socket after multiple attempts, giving up", label)); throw new RuntimeException(e); } i++; @@ -134,8 +163,7 @@ private List buildCommand() { args.add(pathJoin(DATA_DIR, "usr", "share", "qemu")); var hyp = item.optString("hypervisor", "auto"); var hypervisor = VMHypervisor.valueOf(hyp.toUpperCase()); - if (hypervisor == VMHypervisor.AUTO) - hypervisor = VMHypervisor.findPreferredHypervisor(VMBackend.QEMU); + hypervisor = VMHypervisor.resolveConfigured(VMBackend.QEMU, hypervisor); if (hypervisor == null) throw new RuntimeException("No supported hypervisor found for QEMU backend"); args.add("-accel"); var defProtectedMode = ProtectedVM.PROTECTED_NORMAL; @@ -182,7 +210,7 @@ private List buildCommand() { switch (protectedVm) { case PROTECTED_PROTECTED: case PROTECTED_WITHOUT_FIRMWARE: - long swiotlbMb = Math.max(item.optLong("swiotlb_mb", 64), 1); + long swiotlbMb = Math.max(item.optLong("swiotlb_mb", 256), 1); args.add("-object"); args.add(fmt("arm-confidential-guest,id=prot0,swiotlb-size=%dM", swiotlbMb)); break; @@ -211,7 +239,7 @@ private List buildCommand() { } if (item.optBoolean("hugepages", true)) args.add("-mem-prealloc"); - if (item.optBoolean("rng", true)) { + if (!agentMode && item.optBoolean("rng", true)) { args.add("-object"); args.add("rng-random,filename=/dev/urandom,id=rng0"); args.add("-device"); @@ -221,14 +249,25 @@ private List buildCommand() { args.add("-device"); args.add("virtio-balloon-pci,disable-legacy=on,disable-modern=off"); } - buildInputCommand(args); - buildUsbCommand(args); + if (!agentMode) buildInputCommand(args); + if (!agentMode) buildUsbCommand(args); buildDiskCommand(args); - buildNetCommand(args); - buildSharedDirCommand(args); - buildAudioCommand(args); - buildGpuCommand(args); - buildVncCommand(args); + if (!agentMode) { + buildNetCommand(args); + buildSharedDirCommand(args); + buildAudioCommand(args); + buildGpuCommand(args); + buildVncCommand(args); + } + if (agentMode) { + args.add("-chardev"); + args.add(fmt( + "socket,id=agent0,path=%s,server=on,wait=off", agentSocketPath)); + args.add("-device"); + args.add("virtio-serial-pci,id=agent-bus,disable-legacy=on,disable-modern=off"); + args.add("-device"); + args.add("virtconsole,chardev=agent0,name=org.droidvm.agent"); + } args.add("-chardev"); args.add(fmt("socket,id=uart0,path=%s,server=on,wait=on", uartSocketPath)); args.add("-serial"); @@ -308,7 +347,9 @@ private void buildDiskCommand(@NonNull List args) { args.add(fmt("iothread,id=%s", ioId)); var driveArg = new StringBuilder(); driveArg.append(fmt("file=%s,if=none,id=%s", path, drId)); - driveArg.append(",cache=unsafe,aio=threads,discard=unmap"); + driveArg.append(agentMode + ? ",cache=writeback,aio=threads,discard=unmap" + : ",cache=unsafe,aio=threads,discard=unmap"); if (readonly) driveArg.append(",readonly=on"); args.add("-drive"); args.add(driveArg.toString()); @@ -323,7 +364,7 @@ private void buildDiskCommand(@NonNull List args) { } private void buildInputCommand(@NonNull List args) { - if (!config.item.optBoolean("display_enabled", false)) return; + if (!hasAnyScreen()) return; args.add("-device"); args.add("virtio-multitouch-pci,disable-legacy=on,disable-modern=off"); args.add("-device"); @@ -334,7 +375,7 @@ private void buildUsbCommand(@NonNull List args) { if (!config.item.optBoolean("usb", true)) return; args.add("-device"); args.add("qemu-xhci,id=usb-bus,p2=15,p3=15"); - if (config.item.optBoolean("display_enabled", false)) { + if (hasAnyScreen()) { args.add("-device"); args.add("usb-tablet,bus=usb-bus.0"); args.add("-device"); @@ -368,9 +409,27 @@ private void buildSharedDirCommand(@NonNull List args) { } } + /** + * One virtio-sound-pci card on QEMU's aaudio driver, present when the VM has any audio + * peripheral. + * + *

    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.

    + */ private void buildAudioCommand(@NonNull List args) { - var displayEnabled = config.item.optBoolean("display_enabled", false); - if (!config.item.optBoolean("audio_enabled", displayEnabled)) return; + boolean anyAudio = false; + for (var peripheral : VMPeripheralConfig.listOf(config.item)) { + if (peripheral.getType() == PeripheralType.VIRTIO_SOUND) + anyAudio = true; + } + if (!anyAudio) { + if (!config.item.optBoolean("audio_enabled", hasAnyScreen())) return; + } args.add("-audiodev"); args.add("aaudio,id=snd0"); args.add("-device"); @@ -411,13 +470,20 @@ private void buildNetCommand(@NonNull List args) { } } + /** + * The virtio-gpu device (with its renderer, if any) and QEMU's nearest thing to simplefb. + * + *

    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.

    + */ private void buildGpuCommand(@NonNull List args) { var item = config.item; - var useGpu = item.optBoolean("gpu_enabled", false); - var useDisplay = item.optBoolean("display_enabled", false); - if (!useGpu && !useDisplay) return; - var backend = optEnum(item, "display_backend", DisplayBackend.NONE); - if (useGpu) { + var gpuScreen = isScreenEnabled(VMScreenConfig.ID_GPU0); + var fbScreen = isScreenEnabled(VMScreenConfig.ID_SIMPLEFB); + if (!gpuScreen && !fbScreen) return; + if (gpuScreen) { + var gpu0 = VMScreenConfig.of(item, VMScreenConfig.ID_GPU0); var gpuBackend = optEnum(item, "gpu_backend", GpuBackend.NONE); var gpuArg = new StringBuilder(); boolean use3d = false; @@ -431,16 +497,14 @@ private void buildGpuCommand(@NonNull List args) { use3d = true; break; default: + // 2D, or a config that never named a renderer: the plain device, which is what + // "display without acceleration" is on this backend too. gpuArg.append("virtio-gpu-pci"); break; } gpuArg.append(",disable-legacy=on,disable-modern=off"); - if (useDisplay && backend == DisplayBackend.VIRTIO_GPU) { - long width = item.optLong("display_width", 1280); - long height = item.optLong("display_height", 720); - gpuArg.append(fmt(",xres=%d,yres=%d", width, height)); - gpuArg.append(",edid=on"); - } + gpuArg.append(fmt(",xres=%d,yres=%d", gpu0.getWidth(), gpu0.getHeight())); + gpuArg.append(",edid=on"); if (use3d) { gpuArg.append(",blob=on"); args.add("-display"); @@ -448,30 +512,55 @@ private void buildGpuCommand(@NonNull List args) { } args.add("-device"); args.add(gpuArg.toString()); - } else if (backend == DisplayBackend.VIRTIO_GPU) { - long width = item.optLong("display_width", 1280); - long height = item.optLong("display_height", 720); - args.add("-device"); - args.add(fmt("virtio-gpu-pci,disable-legacy=on,disable-modern=off,xres=%d,yres=%d,edid=on", - width, height)); } - if (useDisplay && backend == DisplayBackend.SIMPLEFB) { + // ramfb takes no size: QEMU's firmware-programmed framebuffer gets its geometry from the + // guest, so the simplefb screen's width and height have nowhere to go here. + if (fbScreen) { args.add("-device"); args.add("ramfb"); } } + /** + * Whether the config asks for [screenId]'s display device. + * + *

    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.

    + */ + private boolean hasAnyScreen() { + for (var screen : VMScreenConfig.listOf(config.item)) + if (screen.isEnabled()) return true; + return false; + } + + private boolean isScreenEnabled(@NonNull String screenId) { + var screen = VMScreenConfig.find(config.item, screenId); + return screen != null && screen.isEnabled(); + } + private void buildVncCommand(@NonNull List args) { - var item = config.item; - if (!item.optBoolean("vnc_enabled", false)) return; - var host = item.optString("vnc_host", "0.0.0.0"); + VMScreenConfig bound = null; + for (var screen : VMScreenConfig.listOf(config.item)) + if (screen.isEnabled() && screen.getExporter() == DisplayExporter.VNC) { + bound = screen; + break; + } + if (bound == null) return; + var host = bound.getVncHost(); if (host.isEmpty()) host = "0.0.0.0"; - long port = Math.max(item.optLong("vnc_port", 5900), 1); + long port = Math.max(bound.getVncPort(), 1); long displayNum = port - 5900; if (displayNum < 0) displayNum = 0; var vncArg = new StringBuilder(); + // "host:display", so an IPv6 literal has to be bracketed or its own colons are read as the + // separator. Reachable now that the host field offers the phone's own addresses and half of + // those are v6; an IPv4 address is untouched. + if (host.indexOf(':') >= 0) host = fmt("[%s]", host); vncArg.append(fmt("%s:%d", host, displayNum)); - var password = item.optString("vnc_password", ""); + var password = bound.getVncPassword(); if (!password.isEmpty()) { args.add("-object"); args.add(fmt("secret,id=vnc-password,data=%s", password)); @@ -575,6 +664,7 @@ public boolean hasControlSocket() { @Override public void cleanup() { uartStream.close(); + if (agentStream != null) agentStream.close(); if (qmpSocketPath != null) { deleteFile(qmpSocketPath); qmpSocketPath = null; @@ -583,5 +673,9 @@ public void cleanup() { deleteFile(uartSocketPath); uartSocketPath = null; } + if (agentSocketPath != null) { + deleteFile(agentSocketPath); + agentSocketPath = null; + } } } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMExportTask.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMExportTask.java index 8a8520c3..bee62969 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMExportTask.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMExportTask.java @@ -1,8 +1,12 @@ +// 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.pkg; import static java.nio.charset.StandardCharsets.UTF_8; import static cn.classfun.droidvm.daemon.vm.pkg.VMExportUtils.buildHeader; import static cn.classfun.droidvm.daemon.vm.pkg.VMExportUtils.collectBootFiles; +import static cn.classfun.droidvm.daemon.vm.pkg.VMExportUtils.collectDisks; import static cn.classfun.droidvm.daemon.vm.pkg.VMExportUtils.collectNetworks; import static cn.classfun.droidvm.daemon.vm.pkg.VMExportUtils.sanitizeVM; import static cn.classfun.droidvm.lib.archive.TarWriter.wrapCompressionOutput; @@ -31,8 +35,6 @@ import cn.classfun.droidvm.daemon.server.Server; import cn.classfun.droidvm.lib.archive.RandomAccessFileOutputStream; import cn.classfun.droidvm.lib.archive.TarWriter; -import cn.classfun.droidvm.lib.pkg.DiskEntry; -import cn.classfun.droidvm.lib.pkg.DiskRef; import cn.classfun.droidvm.lib.pkg.PackageConstants; import cn.classfun.droidvm.lib.pkg.PackageManifest; import cn.classfun.droidvm.lib.pkg.Phase; @@ -56,7 +58,7 @@ public final class VMExportTask { public VMExportTask( @NonNull Server server, @NonNull JSONObject request - ) { + ) throws Exception { this.server = server; destPath = request.optString("dest_path"); if (!destPath.startsWith("/")) @@ -75,18 +77,14 @@ public VMExportTask( var wantedSet = new HashSet(); if (wanted != null) for (int i = 0; i < wanted.length(); i++) wantedSet.add(wanted.optInt(i, -1)); - var arr = vm.item.opt("disks", DataItem.newArray()); - for (int i = 0; i < arr.size(); i++) { - var e = arr.get(i); - if (!e.is(DataItem.Type.OBJECT)) continue; - if (!wantedSet.isEmpty() && !wantedSet.contains(i)) continue; - var disk = new DiskEntry(new DiskRef(i, e)); - disk.build(); - manifest.disks.add(disk); - } + // Walks the backing chains, so it can fail on an image whose base is gone. Doing it + // here, before the destination file is touched, keeps that a plain request error. + collectDisks(manifest, vm, wantedSet); collectBootFiles(manifest); collectNetworks(manifest, server.getContext().getNetworks(), vm); manifest.vm.item.remove("disks"); + // Stamp last: the version is the highest any collected part needs. + manifest.manifestVersion = manifest.resolveVersion(); totalItems = manifest.disks.size() + manifest.boots.size(); } diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMExportUtils.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMExportUtils.java index 83f38224..039582f4 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMExportUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMExportUtils.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.pkg; import static java.nio.charset.StandardCharsets.UTF_8; @@ -10,6 +13,7 @@ import static cn.classfun.droidvm.lib.Constants.PATH_MICRODROID_KERNEL; import static cn.classfun.droidvm.lib.utils.BinaryUtils.putInt64LE; import static cn.classfun.droidvm.lib.utils.BinaryUtils.putUInt16LE; +import static cn.classfun.droidvm.lib.utils.FileUtils.canonicalPath; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.util.Log; @@ -17,6 +21,7 @@ import androidx.annotation.NonNull; import java.io.IOException; +import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; @@ -25,12 +30,16 @@ import cn.classfun.droidvm.BuildConfig; import cn.classfun.droidvm.daemon.network.NetworkInstanceStore; import cn.classfun.droidvm.lib.pkg.BootFile; +import cn.classfun.droidvm.lib.pkg.DiskChainPlan; +import cn.classfun.droidvm.lib.pkg.DiskEntry; +import cn.classfun.droidvm.lib.pkg.DiskRef; import cn.classfun.droidvm.lib.pkg.PackageConstants; import cn.classfun.droidvm.lib.pkg.PackageManifest; import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.lib.store.network.NetworkConfig; import cn.classfun.droidvm.lib.store.vm.BootConfig; import cn.classfun.droidvm.lib.store.vm.VMConfig; +import cn.classfun.droidvm.lib.utils.ImageUtils; public final class VMExportUtils { private static final String TAG = "VMExportUtils"; @@ -72,7 +81,7 @@ public static byte[] buildHeader( var hdr = new byte[PackageConstants.HEADER_SIZE]; var magic = PackageConstants.MAGIC.getBytes(UTF_8); System.arraycopy(magic, 0, hdr, 0, magic.length); - putUInt16LE(hdr, 6, PackageConstants.MANIFEST_VERSION); + putUInt16LE(hdr, 6, manifest.manifestVersion); putUInt16LE(hdr, 8, BuildConfig.VERSION_CODE); putUInt16LE(hdr, 10, manifestSize); putUInt16LE(hdr, 12, manifest.compression.type); @@ -99,6 +108,39 @@ private static void addBootFile( manifest.boots.add(file); } + /** + * The files the package must carry for the chosen disks: each selected VM disk plus, when it + * is a qcow2 overlay, every backing image under it. Packing the overlay alone was the whole + * bug - the guest reads the base too, and the copied header points at a path that only ever + * existed on the exporting phone. + * + *

    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 wanted + ) throws Exception { + var tops = new ArrayList(); + var disks = vm.item.opt("disks", DataItem.newArray()); + for (int i = 0; i < disks.size(); i++) { + var item = disks.get(i); + if (!item.is(DataItem.Type.OBJECT)) continue; + if (!wanted.isEmpty() && !wanted.contains(i)) continue; + var ref = new DiskRef(i, item); + if (ref.path.isEmpty()) continue; + // Canonical so that a base image reached both as a disk and as another disk's + // parent is recognised as the same file and packed once. + ref.path = canonicalPath(ref.path); + tops.add(ref); + } + for (var member : DiskChainPlan.build(tops, ImageUtils::backingOf)) + manifest.disks.add(DiskEntry.of(member)); + } + public static void collectBootFiles(@NonNull PackageManifest manifest) { var builtins = new HashSet(); builtins.add(PATH_BUILTIN_KERNEL); @@ -169,7 +211,13 @@ public static void collectNetworks( var ref = exported.optString("pkg_network_ref", ""); if (netId.isEmpty() || ref.isEmpty() || !seen.add(netId)) continue; var net = store.findById(netId); - if (net == null) continue; + if (net == null) { + // The app registers every network it writes, so this means the registration was + // refused or never reached us. Say so: the package comes out with a NIC that + // references a network it does not carry, and nothing else would report it. + Log.w(TAG, fmt("Network %s is unknown here; leaving it out of the package", netId)); + continue; + } try { var cfg = new NetworkConfig(net.toJson()); scrubUnique(cfg.item); diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMImportTask.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMImportTask.java index 9f792600..c191d9f3 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMImportTask.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMImportTask.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.pkg; import static cn.classfun.droidvm.daemon.vm.pkg.VMImportUtils.remapBootPaths; @@ -5,9 +8,11 @@ import static cn.classfun.droidvm.daemon.vm.pkg.VMImportUtils.uniqueFile; import static cn.classfun.droidvm.lib.pkg.PackageConstants.BUFFER; import static cn.classfun.droidvm.lib.utils.BinaryUtils.readFully; +import static cn.classfun.droidvm.lib.utils.NetUtils.generateRandomMac; import static cn.classfun.droidvm.lib.utils.JsonUtils.listToJSONArray; import static cn.classfun.droidvm.lib.utils.StringUtils.basename; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.safeFileName; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; import android.util.Log; @@ -24,14 +29,15 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.UUID; -import java.util.concurrent.atomic.AtomicBoolean; import cn.classfun.droidvm.daemon.server.Server; import cn.classfun.droidvm.lib.archive.TarReader; import cn.classfun.droidvm.lib.pkg.BootFile; import cn.classfun.droidvm.lib.pkg.DiskEntry; +import cn.classfun.droidvm.lib.pkg.NetworkImportPlan; import cn.classfun.droidvm.lib.pkg.PackageConstants; import cn.classfun.droidvm.lib.pkg.PackageHeader; import cn.classfun.droidvm.lib.pkg.PackageInput; @@ -40,7 +46,10 @@ import cn.classfun.droidvm.lib.pkg.VolumeSet; import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.lib.store.network.NetworkConfig; +import cn.classfun.droidvm.lib.store.vm.NicLeaseOffsets; import cn.classfun.droidvm.lib.store.vm.VMConfig; +import cn.classfun.droidvm.lib.store.vm.VMNicConfig; +import cn.classfun.droidvm.lib.utils.ImageUtils; public final class VMImportTask { private static final String TAG = "VMImportTask"; @@ -49,7 +58,13 @@ public final class VMImportTask { private final Server server; private final String srcPath; private final File targetDir; - private final String networkMode; + /** What to do with a packaged network the plan says nothing about. */ + private final NetworkImportPlan.Action networkFallback; + private final List networkPlan; + /** This package's own folder under {@link #targetDir}; created before the first file lands. */ + private File vmDir = null; + private String vmName = ""; + private boolean registered = false; private int totalItems; private int volumeTotal = 0; public VMConfig importedVM = null; @@ -66,7 +81,11 @@ public VMImportTask(@NonNull Server server, @NonNull JSONObject request) { if (!targetPath.startsWith("/")) throw new IllegalArgumentException("missing target_dir"); targetDir = new File(targetPath); - networkMode = request.optString("network_mode", "auto"); + networkPlan = NetworkImportPlan.parse(request.optJSONArray("network_plan")); + // A caller with no plan gets the old whole-package behaviour: "skip" attaches nothing, + // anything else recreates every network the package carries. + networkFallback = request.optString("network_mode", "auto").equals("skip") + ? NetworkImportPlan.Action.SKIP : NetworkImportPlan.Action.CREATE; } public void startAsync() { @@ -77,10 +96,11 @@ private void run() { var data = DataItem.newObject(); try { unpack(); - importedVM.setName(uniqueVMName(importedVM.getName())); var networks = importNetworks(importedVM, importedManifest.networks); + resolveLeases(importedVM); var vmId = server.getContext().getVMs().createVM(importedVM); if (vmId == null || vmId.isEmpty()) throw new IOException("failed to register VM"); + registered = true; data.set("done", totalItems); data.set("total", totalItems); data.set("vm_id", vmId); @@ -91,6 +111,7 @@ private void run() { emit(data, Phase.DONE); } catch (Exception e) { Log.w(TAG, fmt("Import task %s failed", taskId), e); + discardPlacedFiles(); data.set("done", 0); data.set("total", totalItems); data.set("message", e.getMessage()); @@ -108,7 +129,7 @@ private void onTarItem( ) throws Exception{ var disk = importedManifest.findDisk(name); if (disk != null) { - var target = uniqueFile(targetDir, disk.name); + var target = uniqueFile(vmDir, disk.name); copyEntry(content, target, size, placedDisks.size(), totalItems); disk.target = target; placedDisks.add(disk); @@ -116,7 +137,7 @@ private void onTarItem( } var boot = importedManifest.findBoot(name); if (boot != null) { - var dir = new File(targetDir, "boot"); + var dir = new File(vmDir, "boot"); if (!dir.exists() && !dir.mkdirs()) throw new IOException(fmt("Cannot create %s", dir)); var target = uniqueFile(dir, boot.name); @@ -151,11 +172,71 @@ private void unpack() throws Exception { } var vm = new VMConfig(importedManifest.vm.toJson()); vm.setId(UUID.randomUUID()); + vm.setName(vmName); + relinkBackingChains(); remapDiskPaths(vm, placedDisks); remapBootPaths(vm, placedBoots); importedVM = vm; } + /** + * Re-point each imported overlay at the copy of its backing image that travelled with it. + * The packed header still names the exporting phone's path - exporting reads the source + * images and never rewrites them - so this is where a chain becomes usable again. Header + * only: the data is already there, the files just live somewhere else now. + */ + private void relinkBackingChains() throws IOException { + var byArchive = new HashMap(); + for (var disk : placedDisks) byArchive.put(disk.archivePath, disk); + for (var disk : placedDisks) { + if (disk.backingArchive.isEmpty() || disk.target == null) continue; + var parent = byArchive.get(disk.backingArchive); + if (parent == null || parent.target == null) throw new IOException(fmt( + "package is missing the backing image %s needed by %s", + disk.backingArchive, disk.archivePath + )); + ImageUtils.rebaseBacking(disk.target.getPath(), parent.target.getPath()); + } + } + + /** + * Drop what a failed import wrote. Safe to do bluntly because everything it wrote is inside + * one folder this import created for itself; nothing else has ever been in there. + */ + private void discardPlacedFiles() { + var dir = vmDir; + if (dir == null || registered) return; + vmDir = null; + try { + deleteTree(dir); + } catch (Exception e) { + Log.w(TAG, fmt("Failed to clean up %s", dir), e); + } + } + + private static void deleteTree(@NonNull File file) { + var children = file.listFiles(); + if (children != null) for (var child : children) deleteTree(child); + //noinspection ResultOfMethodCallIgnored + file.delete(); + } + + /** + * The folder this package's files go in: named after the VM, unique within the chosen + * import folder. One package's disks - a backing chain can be several - stay together + * instead of piling into a folder shared with every other VM's images. + */ + @NonNull + private File createVMDir(@NonNull String name) throws IOException { + var base = safeFileName(name, "vm"); + var dir = new File(targetDir, base); + for (int i = 1; dir.exists(); i++) + dir = new File(targetDir, fmt("%s_%d", base, i)); + if (!dir.mkdirs()) + throw new IOException(fmt("Cannot create %s", dir)); + return dir; + } + private int readVolumeCount(@NonNull String masterPath) throws Exception { try (var in = new FileInputStream(masterPath)) { var hdr = new byte[PackageConstants.HEADER_SIZE]; @@ -166,6 +247,10 @@ private int readVolumeCount(@NonNull String masterPath) throws Exception { private void extract(@NonNull PackageInput pkg) throws Exception { importedManifest = pkg.manifest; + // Settle the name and make the folder before any byte lands in it, so the files are + // together from the start and a failure has exactly one thing to clean up. + vmName = uniqueVMName(importedManifest.vm.getName()); + vmDir = createVMDir(vmName); totalItems = importedManifest.disks.size() + importedManifest.boots.size(); var data = DataItem.newObject(); data.set("done", 0); @@ -179,6 +264,14 @@ private void extract(@NonNull PackageInput pkg) throws Exception { pkg.validateDataConsumed(); } + /** + * Applies the import plan: each network the package carries is joined to one this phone + * already has, created here, or left behind, and every NIC that referenced it is re-pointed + * at what it ended up on. A NIC whose network was skipped -- or whose join target has since + * been deleted -- comes out unattached rather than pointing at nothing. + * + * @return the networks this import created, for the caller to persist + */ @NonNull private JSONArray importNetworks( @NonNull VMConfig vm, @@ -186,24 +279,28 @@ private JSONArray importNetworks( ) throws Exception { var refs = new HashMap(); var created = new JSONArray(); - if (networkMode.equals("skip")) { - remapNetworks(vm, refs); - return created; - } var store = server.getContext().getNetworks(); + var plan = new NetworkImportPlan(store); for (var source : configs) { - var ref = source.item.optString("pkg_network_ref", ""); + var ref = source.item.optString(NetworkImportPlan.REF_KEY, ""); if (ref.isEmpty()) continue; - if (networkMode.equals("existing")) { - var existing = store.findByName(source.getName()); - if (existing != null) refs.put(ref, existing.getId().toString()); + var entry = NetworkImportPlan.findRef(networkPlan, ref); + var action = entry == null ? networkFallback : entry.action; + if (action == NetworkImportPlan.Action.SKIP) continue; + if (action == NetworkImportPlan.Action.JOIN) { + var target = entry == null || entry.networkId == null + ? null : store.findById(entry.networkId); + if (target == null) { + Log.w(TAG, fmt("Import %s: no network to join for %s", taskId, ref)); + continue; + } + refs.put(ref, target.getId().toString()); continue; } - var cfg = new NetworkConfig(source.toJson()); - cfg.item.remove("pkg_network_ref"); - cfg.setId(UUID.randomUUID()); - cfg.setName(uniqueNetworkName(cfg.getName())); - makeBridgeNameUnique(cfg); + // The screen prepared the config so the user could see what it would be; take it, + // but let the plan settle the names again against the store as it is right now. + var cfg = entry != null && entry.config != null + ? plan.adopt(entry.config) : plan.prepareCreate(source); var id = store.createNetwork(cfg); if (id == null || id.isEmpty()) continue; refs.put(ref, id); @@ -221,38 +318,65 @@ private void remapNetworks( if (nets == null || !nets.is(DataItem.Type.ARRAY)) return; for (var nic : nets.asArray()) { if (!nic.is(DataItem.Type.OBJECT)) continue; - var ref = nic.optString("pkg_network_ref", ""); - nic.remove("pkg_network_ref"); + var ref = nic.optString(NetworkImportPlan.REF_KEY, ""); + nic.remove(NetworkImportPlan.REF_KEY); var id = refs.get(ref); if (id == null || id.isEmpty()) nic.remove("network_id"); else nic.set("network_id", id); } } - @NonNull - private String uniqueNetworkName(@NonNull String name) { - var store = server.getContext().getNetworks(); - if (store.findByName(name) == null) return name; - int i = 1; - while (store.findByName(fmt("%s_%d", name, i)) != null) i++; - return fmt("%s_%d", name, i); - } - - private void makeBridgeNameUnique(@NonNull NetworkConfig cfg) { - var bridge = cfg.getBridgeName(); - if (bridge == null || bridge.isEmpty()) return; - if (isBridgeNameUnique(bridge)) return; - int i = 1; - while (!isBridgeNameUnique(fmt("%s%d", bridge, i))) i++; - cfg.setBridgeName(fmt("%s%d", bridge, i)); + /** + * Makes every static DHCP lease the package brought fit the network its NIC actually landed + * on. An offset that came from the other phone is kept where it can be: it is what the guest + * has been answering on. Where it cannot -- another VM here already holds it, it falls inside + * this VLAN's dynamic pool, or the VLAN is smaller than it was over there -- the next free + * offset is taken instead, and only when the VLAN has nothing free at all (or does not serve + * that family, or the NIC ended up on no network) does the lease go back to a dynamic + * address. Refusing the import over an address a VM can perfectly well be given by DHCP + * would help nobody. + */ + private void resolveLeases(@NonNull VMConfig vm) { + vm.forEachNic(nic -> { + resolveLease(vm, nic, NicLeaseOffsets.Family.IPV4); + resolveLease(vm, nic, NicLeaseOffsets.Family.IPV6); + }); } - private boolean isBridgeNameUnique(@NonNull String bridgeName) { - var unique = new AtomicBoolean(true); - server.getContext().getNetworks().forEach((id, net) -> { - if (bridgeName.equals(net.getBridgeName())) unique.set(false); - }); - return unique.get(); + private void resolveLease( + @NonNull VMConfig vm, + @NonNull VMNicConfig nic, + @NonNull NicLeaseOffsets.Family family + ) { + boolean ipv6 = family == NicLeaseOffsets.Family.IPV6; + if (!(ipv6 ? nic.isDhcp6LeaseEnabled() : nic.isDhcp4LeaseEnabled())) return; + var netId = nic.getNetworkId(); + var network = netId == null ? null : server.getContext().getNetworks().findById(netId); + var vlan = network == null ? null : nic.resolveDhcpVlan(network); + if (vlan == null || !(ipv6 ? vlan.isDhcp6Enabled() : vlan.isDhcp4Enabled())) { + nic.setDhcpLeaseEnabled(ipv6, false); + return; + } + var used = new HashSet(); + server.getContext().getVMs().forEach((id, other) -> + NicLeaseOffsets.addOffsets(used, other, network, vlan, family)); + // this VM is not registered yet, so its own other NICs are only in the config in hand + NicLeaseOffsets.addOffsets(used, vm, network, vlan, family, nic); + boolean has = ipv6 ? nic.hasDhcp6Offset() : nic.hasDhcp4Offset(); + long wanted = !has ? NicLeaseOffsets.FIRST + : (ipv6 ? nic.getDhcp6Offset() : nic.getDhcp4Offset()); + long offset = NicLeaseOffsets.resolve(wanted, used, vlan, family); + if (offset < 0) { + Log.w(TAG, fmt("Import %s: no free lease offset on network %s", taskId, netId)); + nic.setDhcpLeaseEnabled(ipv6, false); + return; + } + if (ipv6) nic.setDhcp6Offset(offset); + else nic.setDhcp4Offset(offset); + // Exporting strips NIC MAC addresses -- two phones must not hand out the same one -- but + // a static lease is keyed by MAC, so a kept lease needs one now rather than whenever the + // user next opens the NIC editor. + if (nic.getMacAddress() == null) nic.item.set("mac_address", generateRandomMac()); } private void copyEntry( diff --git a/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMImportUtils.java b/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMImportUtils.java index 4f47f12a..df6ba273 100644 --- a/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMImportUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/daemon/vm/pkg/VMImportUtils.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.pkg; import static java.util.Objects.requireNonNull; @@ -7,6 +10,7 @@ import java.io.File; import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import cn.classfun.droidvm.lib.pkg.BootFile; @@ -19,13 +23,26 @@ public final class VMImportUtils { private VMImportUtils() { } + /** + * Rebuild the VM's disk list from the files the import placed. Only entries that fill a disk + * slot go in: a backing image is on disk for the overlay above it to read, not for the guest + * to see. Slot order comes from the manifest rather than from the order the tar happened to + * store the files in, which no longer matches once a chain is interleaved with its disks. + */ public static void remapDiskPaths( @NonNull VMConfig vm, @NonNull ArrayList placed ) { - var disks = DataItem.newArray(); + var attached = new ArrayList(); for (var disk : placed) { - if (disk.target == null || disk.ref == null) continue; + if (disk.target == null || disk.ref == null || !disk.attached) continue; + attached.add(disk); + } + // Stable: packages written before the slot index existed carry 0 for every entry, and + // keep the order they were packed in. + attached.sort(Comparator.comparingInt((DiskEntry disk) -> disk.ref.index)); + var disks = DataItem.newArray(); + for (var disk : attached) { var item = DataItem.newObject(); item.set("path", disk.target.getPath()); item.set("readonly", disk.ref.readonly); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/Constants.java b/app/src/main/java/cn/classfun/droidvm/lib/Constants.java index 852e863f..6a082d81 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/Constants.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/Constants.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; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/api/ApiInfo.java b/app/src/main/java/cn/classfun/droidvm/lib/api/ApiInfo.java index 47fdde35..c6725f77 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/api/ApiInfo.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/api/ApiInfo.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.api; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/api/ApiManager.java b/app/src/main/java/cn/classfun/droidvm/lib/api/ApiManager.java index b1f1cabd..753b8c6e 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/api/ApiManager.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/api/ApiManager.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.api; import static java.util.Objects.requireNonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/api/ApiServiceInfo.java b/app/src/main/java/cn/classfun/droidvm/lib/api/ApiServiceInfo.java index cd35cee5..e5f90dc0 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/api/ApiServiceInfo.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/api/ApiServiceInfo.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.api; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/api/Privacy.java b/app/src/main/java/cn/classfun/droidvm/lib/api/Privacy.java index 118fe518..7de20ecd 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/api/Privacy.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/api/Privacy.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.api; import static cn.classfun.droidvm.lib.utils.AssetUtils.loadFromAssets; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/archive/Compression.java b/app/src/main/java/cn/classfun/droidvm/lib/archive/Compression.java index 059234a2..fb0c732e 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/archive/Compression.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/archive/Compression.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.archive; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/archive/LimitedInputStream.java b/app/src/main/java/cn/classfun/droidvm/lib/archive/LimitedInputStream.java index 451ccfa0..3b3a2648 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/archive/LimitedInputStream.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/archive/LimitedInputStream.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.archive; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/archive/OutputStreamLimiter.java b/app/src/main/java/cn/classfun/droidvm/lib/archive/OutputStreamLimiter.java index 8abc2991..bd6ff1f9 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/archive/OutputStreamLimiter.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/archive/OutputStreamLimiter.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.archive; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/archive/RandomAccessFileOutputStream.java b/app/src/main/java/cn/classfun/droidvm/lib/archive/RandomAccessFileOutputStream.java index 6d32422c..4a968d59 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/archive/RandomAccessFileOutputStream.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/archive/RandomAccessFileOutputStream.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.archive; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/archive/TarReader.java b/app/src/main/java/cn/classfun/droidvm/lib/archive/TarReader.java index 34f9b55e..d9f29122 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/archive/TarReader.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/archive/TarReader.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.archive; import static java.nio.charset.StandardCharsets.UTF_8; @@ -125,6 +128,14 @@ private static String cstr(@NonNull byte[] hdr, int off, int len) { } private static long parseOctal(@NonNull byte[] hdr, int off, int len) { + // GNU base-256: bit 7 of the first byte set, the rest big-endian. GNU tar, bsdtar and + // Python's tarfile all write sizes >= 8 GiB (12 octal digits won't fit) this way, and + // without this branch such an entry parsed as 0 -> a 0-byte disk on import. + if ((hdr[off] & 0x80) != 0) { + long v = hdr[off] & 0x7f; + for (int i = off + 1; i < off + len; i++) v = (v << 8) | (hdr[i] & 0xff); + return v; + } var s = cstr(hdr, off, len); if (s.isEmpty()) return 0; var trimmed = s.trim(); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/archive/TarWriter.java b/app/src/main/java/cn/classfun/droidvm/lib/archive/TarWriter.java index c47f0120..646f7d54 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/archive/TarWriter.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/archive/TarWriter.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.archive; import static java.nio.charset.StandardCharsets.US_ASCII; @@ -23,6 +26,9 @@ public final class TarWriter implements AutoCloseable { private static final int BLOCK = 512; + // Fixed zstd worker count: each one costs roughly jobSize * 2 of extra + // memory, and the export runs next to whatever else the host is doing. + private static final int ZSTD_WORKERS = 4; private final OutputStream out; private boolean closed = false; @@ -108,7 +114,19 @@ private void writeHeader( field(hdr, 100, 8, padOctal(metadata.mode, 7)); field(hdr, 108, 8, padOctal(metadata.uid, 7)); field(hdr, 116, 8, padOctal(metadata.gid, 7)); - field(hdr, 124, 12, padOctal(size, 11)); + if (size > 0777777777777L) { + // 12 octal digits top out just under 64 GiB; past that GNU base-256 (bit 7 flag, then + // big-endian), which TarReader, GNU tar, bsdtar and Python all read. Before this the + // 13-digit string was silently cut to 12 and the entry came out with a wrong size. + hdr[124] = (byte) 0x80; + long v = size; + for (int i = 135; i >= 125; i--) { + hdr[i] = (byte) (v & 0xff); + v >>= 8; + } + } else { + field(hdr, 124, 12, padOctal(size, 11)); + } field(hdr, 136, 12, padOctal(metadata.mtime, 11)); for (int i = 148; i < 156; i++) hdr[i] = ' '; hdr[156] = (byte) type; @@ -156,7 +174,11 @@ public static OutputStream wrapCompressionOutput( case XZ: return new XZOutputStream(out, new LZMA2Options()); case ZSTD: - return new ZstdOutputStream(out, 3); + // setWorkers must be called before the first write, otherwise + // zstd-jni rejects it with IllegalStateException. Workers only + // affect encoder internals: the output stays a single standard + // zstd frame that any reader can decode. + return new ZstdOutputStream(out, 3).setWorkers(ZSTD_WORKERS); default: throw new IllegalArgumentException(fmt("unknown compression: %s", c)); } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/crypt/HashFile.java b/app/src/main/java/cn/classfun/droidvm/lib/crypt/HashFile.java index 1e81355e..4ffd92ec 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/crypt/HashFile.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/crypt/HashFile.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.crypt; import static cn.classfun.droidvm.lib.utils.FileUtils.loadJSONFile; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/crypt/HashItem.java b/app/src/main/java/cn/classfun/droidvm/lib/crypt/HashItem.java index eba503a7..97b8b078 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/crypt/HashItem.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/crypt/HashItem.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.crypt; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonClient.java b/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonClient.java index fcd02dfb..d0576735 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonClient.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonClient.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.daemon; import static cn.classfun.droidvm.lib.daemon.Protocol.IPC_REQUEST_TIMEOUT_MS; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonConnection.java b/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonConnection.java index 3fb894f4..3588af70 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonConnection.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonConnection.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.daemon; import static cn.classfun.droidvm.lib.daemon.DaemonHelper.getPortFile; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonHelper.java b/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonHelper.java index 3668efab..50a8d69b 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonHelper.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/daemon/DaemonHelper.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.daemon; import static android.widget.Toast.LENGTH_LONG; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/daemon/ForegroundCallback.java b/app/src/main/java/cn/classfun/droidvm/lib/daemon/ForegroundCallback.java index a210ab14..bc782b65 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/daemon/ForegroundCallback.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/daemon/ForegroundCallback.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.daemon; import org.json.JSONObject; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/daemon/PendingRequest.java b/app/src/main/java/cn/classfun/droidvm/lib/daemon/PendingRequest.java index 768d3eaf..afcd7ef7 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/daemon/PendingRequest.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/daemon/PendingRequest.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.daemon; import org.json.JSONObject; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/daemon/Protocol.java b/app/src/main/java/cn/classfun/droidvm/lib/daemon/Protocol.java index 5668a2c6..46407334 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/daemon/Protocol.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/daemon/Protocol.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.daemon; import static java.nio.ByteOrder.LITTLE_ENDIAN; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/daemon/RequestContext.java b/app/src/main/java/cn/classfun/droidvm/lib/daemon/RequestContext.java index a8f89320..497f8d9f 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/daemon/RequestContext.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/daemon/RequestContext.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.daemon; import android.util.Log; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/daemon/VMEventHandler.java b/app/src/main/java/cn/classfun/droidvm/lib/daemon/VMEventHandler.java index da8923c4..19f32545 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/daemon/VMEventHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/daemon/VMEventHandler.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.daemon; import static android.content.Context.MODE_PRIVATE; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/CrosvmExit.java b/app/src/main/java/cn/classfun/droidvm/lib/data/CrosvmExit.java index db4920b3..15913e50 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/data/CrosvmExit.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/CrosvmExit.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.data; /** diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/HostAudioDevices.java b/app/src/main/java/cn/classfun/droidvm/lib/data/HostAudioDevices.java new file mode 100644 index 00000000..2d83b2c3 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/HostAudioDevices.java @@ -0,0 +1,385 @@ +// 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.data; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.content.Context; +import android.media.AudioDeviceInfo; +import android.media.AudioManager; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import cn.classfun.droidvm.R; + +/** + * Host audio endpoints, as Android reports them, in a form that survives a reboot. + * + *

    {@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.

    + */ +public final class HostAudioDevices { + private static final String TAG = "HostAudioDevices"; + /** AAUDIO_UNSPECIFIED: let the platform route the stream itself. */ + public static final int DEVICE_UNSPECIFIED = 0; + + /** + * The endpoint that means "whatever the platform would route to", named rather than left + * blank. + * + *

    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.

    + */ + public static final String SYSTEM_DEFAULT_KEY = "DEFAULT|system default"; + + /** One live host endpoint. */ + public static final class Entry { + /** Stable descriptor stored in the VM config; see {@link #keyOf}. */ + public final String key; + /** Human-readable name for the picker. */ + public final String label; + /** Live AudioDeviceInfo id, valid only for this boot. */ + public final int id; + + Entry(@NonNull String key, @NonNull String label, int id) { + this.key = key; + this.label = label; + this.id = id; + } + } + + private HostAudioDevices() { + } + + /** + * Live output ({@code input == false}) or input endpoints, deduplicated by key. Empty when + * AudioManager is unreachable -- callers fall back to "platform default" routing. + */ + @NonNull + public static List list(@NonNull Context context, boolean input) { + var out = new ArrayList(); + var devices = query(context, input); + if (devices == null) return out; + for (var device : devices) { + // A sink that can't be targeted is noise in the picker (telephony, the guest's own + // loopback, ...); AAudio can only open real endpoints anyway. + if (!isSelectable(device, input)) continue; + var key = keyOf(device); + boolean dup = false; + for (var seen : out) + if (seen.key.equals(key)) dup = true; + if (dup) continue; + out.add(new Entry(key, labelOf(context, device), device.getId())); + } + return out; + } + + /** + * The same endpoints as {@link #list}, as {@code id -> key} pairs and nothing else. + * + *

    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.

    + */ + @NonNull + public static List idsAndKeys(@NonNull Context context, boolean input, + @NonNull List keysOut) { + var ids = new ArrayList(); + var devices = query(context, input); + if (devices == null) return ids; + for (var device : devices) { + if (!isSelectable(device, input)) continue; + var key = keyOf(device); + if (keysOut.contains(key)) continue; + keysOut.add(key); + ids.add(new int[] { + device.getId(), soleValue(device.getSampleRates()), + soleValue(device.getChannelCounts()), kindOf(device, input) + }); + } + return ids; + } + + /** + * The one value in a capability list, or 0 when there is more than one. + * + *

    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.

    + */ + private static int soleValue(int[] values) { + return values != null && values.length == 1 ? values[0] : 0; + } + + /** + * What kind of thing the endpoint is, in the numbering the guest driver uses: 1 speaker, + * 2 headphones, 3 headset, 4 line out, 5 digital, 6 microphone, 7 telephony. + * + *

    This is what decides the name and icon Windows shows beside the endpoint, so an + * approximate answer is still much better than none.

    + */ + public static int kindOf(@NonNull AudioDeviceInfo device, boolean input) { + switch (device.getType()) { + case AudioDeviceInfo.TYPE_TELEPHONY: + return 7; + case AudioDeviceInfo.TYPE_WIRED_HEADPHONES: + return 2; + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + case AudioDeviceInfo.TYPE_USB_HEADSET: + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + case AudioDeviceInfo.TYPE_BLE_HEADSET: + return 3; + case AudioDeviceInfo.TYPE_LINE_ANALOG: + case AudioDeviceInfo.TYPE_AUX_LINE: + return 4; + case AudioDeviceInfo.TYPE_HDMI: + case AudioDeviceInfo.TYPE_HDMI_ARC: + case AudioDeviceInfo.TYPE_LINE_DIGITAL: + case AudioDeviceInfo.TYPE_USB_DEVICE: + case AudioDeviceInfo.TYPE_USB_ACCESSORY: + case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP: + case AudioDeviceInfo.TYPE_BLE_SPEAKER: + return 5; + default: + // Everything left is a built-in transducer of one kind or the other, and which + // one is decided by the direction rather than by the type. + return input ? 6 : 1; + } + } + + /** + * Live AudioDeviceInfo id for a stored key, or {@link #DEVICE_UNSPECIFIED} when the key is + * empty (follow the platform) or names a device that is not currently present. + */ + public static int resolve(@NonNull Context context, boolean input, @Nullable String key) { + // "" is what older configs stored for the same thing. + if (key == null || key.isEmpty() || SYSTEM_DEFAULT_KEY.equals(key)) { + return DEVICE_UNSPECIFIED; + } + var devices = query(context, input); + if (devices == null) return DEVICE_UNSPECIFIED; + for (var device : devices) + if (keyOf(device).equals(key)) return device.getId(); + Log.w(TAG, fmt("host audio device %s is not present; falling back to default routing", key)); + return DEVICE_UNSPECIFIED; + } + + /** + * Separates the endpoint's name from what the stream is for. + * + *

    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.

    + */ + public static final char ATTR_SEPARATOR = '#'; + + /** The part of a stored key that names the endpoint, without what it is to be used for. */ + @NonNull + public static String deviceOf(@NonNull String key) { + int at = key.indexOf(ATTR_SEPARATOR); + return at < 0 ? key : key.substring(0, at); + } + + /** The {@code attr=value,...} part of a stored key, or "" when it carries none. */ + @NonNull + public static String attrsOf(@NonNull String key) { + int at = key.indexOf(ATTR_SEPARATOR); + return at < 0 ? "" : key.substring(at + 1); + } + + /** One attribute out of the {@code attr=value,...} part, or "" when it is not set. */ + @NonNull + public static String attrOf(@NonNull String key, @NonNull String name) { + for (var pair : attrsOf(key).split(",")) { + int eq = pair.indexOf('='); + if (eq > 0 && pair.substring(0, eq).trim().equals(name)) { + return pair.substring(eq + 1).trim(); + } + } + return ""; + } + + /** Rebuilds a key from an endpoint and its attributes; empty attributes are left out. */ + @NonNull + public static String withAttrs(@NonNull String device, @NonNull List names, + @NonNull List values) { + var parts = new ArrayList(); + for (int i = 0; i < names.size() && i < values.size(); i++) { + if (!values.get(i).isEmpty()) { + parts.add(fmt("%s=%s", names.get(i), values.get(i))); + } + } + return parts.isEmpty() ? device : device + ATTR_SEPARATOR + String.join(",", parts); + } + + /** + * Stable descriptor for one endpoint: {@code "|
    "}. The type name (not its + * numeric constant) keeps the config readable, and the address separates one paired headset + * or USB card from another of the same type. + */ + @NonNull + public static String keyOf(@NonNull AudioDeviceInfo device) { + var address = ""; + try { + address = device.getAddress(); + } catch (Throwable ignored) { + // getAddress() is @SystemApi-adjacent on some builds; the type alone still works + } + return fmt("%s|%s", typeName(device.getType()), address == null ? "" : address); + } + + /** Localized type name, with the product name or address appended when it disambiguates. */ + @NonNull + public static String labelOf(@NonNull Context context, @NonNull AudioDeviceInfo device) { + var name = typeLabel(context, device.getType()); + var product = String.valueOf(device.getProductName()).trim(); + var address = ""; + try { + address = device.getAddress(); + } catch (Throwable ignored) { + } + if (address != null && !address.isEmpty()) + return fmt("%s (%s)", name, address); + if (!product.isEmpty() && !product.equalsIgnoreCase(name)) + return fmt("%s (%s)", name, product); + return name; + } + + @Nullable + private static AudioDeviceInfo[] query(@NonNull Context context, boolean input) { + try { + var am = context.getSystemService(AudioManager.class); + if (am == null) { + Log.w(TAG, "AudioManager unavailable"); + return null; + } + return am.getDevices(input + ? AudioManager.GET_DEVICES_INPUTS + : AudioManager.GET_DEVICES_OUTPUTS); + } catch (Throwable t) { + Log.w(TAG, "failed to enumerate host audio devices", t); + return null; + } + } + + /** + * The echo reference: the output mix, fed back so a capture path can subtract it. Android + * exposes it in the device list but only opens it for system callers, so an endpoint pinned + * to it appears in the guest and never produces a sample -- measured: PREPARE comes back + * VIRTIO_SND_S_IO_ERR, "Failed to open stream". + * + *

    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.

    + */ + private static final int TYPE_ECHO_REFERENCE = 28; + + /** Endpoints that make no sense as a VM's speaker or microphone. */ + private static boolean isSelectable(@NonNull AudioDeviceInfo device, boolean input) { + switch (device.getType()) { + case TYPE_ECHO_REFERENCE: + case AudioDeviceInfo.TYPE_TELEPHONY: + case AudioDeviceInfo.TYPE_REMOTE_SUBMIX: + case AudioDeviceInfo.TYPE_FM: + case AudioDeviceInfo.TYPE_FM_TUNER: + case AudioDeviceInfo.TYPE_TV_TUNER: + return false; + default: + return input ? device.isSource() : device.isSink(); + } + } + + /** Stable, config-visible name for an AudioDeviceInfo type constant. */ + @NonNull + private static String typeName(int type) { + switch (type) { + case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE: return "BUILTIN_EARPIECE"; + case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER: return "BUILTIN_SPEAKER"; + case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE: return "BUILTIN_SPEAKER_SAFE"; + case AudioDeviceInfo.TYPE_WIRED_HEADSET: return "WIRED_HEADSET"; + case AudioDeviceInfo.TYPE_WIRED_HEADPHONES: return "WIRED_HEADPHONES"; + case AudioDeviceInfo.TYPE_LINE_ANALOG: return "LINE_ANALOG"; + case AudioDeviceInfo.TYPE_LINE_DIGITAL: return "LINE_DIGITAL"; + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: return "BLUETOOTH_SCO"; + case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP: return "BLUETOOTH_A2DP"; + case AudioDeviceInfo.TYPE_BLE_HEADSET: return "BLE_HEADSET"; + case AudioDeviceInfo.TYPE_BLE_SPEAKER: return "BLE_SPEAKER"; + case AudioDeviceInfo.TYPE_HDMI: return "HDMI"; + case AudioDeviceInfo.TYPE_HDMI_ARC: return "HDMI_ARC"; + case AudioDeviceInfo.TYPE_USB_DEVICE: return "USB_DEVICE"; + case AudioDeviceInfo.TYPE_USB_ACCESSORY: return "USB_ACCESSORY"; + case AudioDeviceInfo.TYPE_USB_HEADSET: return "USB_HEADSET"; + case AudioDeviceInfo.TYPE_DOCK: return "DOCK"; + case AudioDeviceInfo.TYPE_AUX_LINE: return "AUX_LINE"; + case AudioDeviceInfo.TYPE_IP: return "IP"; + case AudioDeviceInfo.TYPE_BUS: return "BUS"; + case AudioDeviceInfo.TYPE_BUILTIN_MIC: return "BUILTIN_MIC"; + case AudioDeviceInfo.TYPE_REMOTE_SUBMIX: return "REMOTE_SUBMIX"; + case AudioDeviceInfo.TYPE_TELEPHONY: return "TELEPHONY"; + case AudioDeviceInfo.TYPE_FM: return "FM"; + case AudioDeviceInfo.TYPE_FM_TUNER: return "FM_TUNER"; + case AudioDeviceInfo.TYPE_TV_TUNER: return "TV_TUNER"; + default: return fmt("TYPE_%d", type); + } + } + + /** Localized name for the picker; unlisted types fall back to the stable name. */ + @NonNull + private static String typeLabel(@NonNull Context context, int type) { + switch (type) { + case AudioDeviceInfo.TYPE_BUILTIN_EARPIECE: + return context.getString(R.string.audio_device_builtin_earpiece); + case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER: + case AudioDeviceInfo.TYPE_BUILTIN_SPEAKER_SAFE: + return context.getString(R.string.audio_device_builtin_speaker); + case AudioDeviceInfo.TYPE_WIRED_HEADSET: + return context.getString(R.string.audio_device_wired_headset); + case AudioDeviceInfo.TYPE_WIRED_HEADPHONES: + return context.getString(R.string.audio_device_wired_headphones); + case AudioDeviceInfo.TYPE_BLUETOOTH_SCO: + return context.getString(R.string.audio_device_bluetooth_sco); + case AudioDeviceInfo.TYPE_BLUETOOTH_A2DP: + case AudioDeviceInfo.TYPE_BLE_HEADSET: + case AudioDeviceInfo.TYPE_BLE_SPEAKER: + return context.getString(R.string.audio_device_bluetooth); + case AudioDeviceInfo.TYPE_USB_DEVICE: + case AudioDeviceInfo.TYPE_USB_ACCESSORY: + case AudioDeviceInfo.TYPE_USB_HEADSET: + return context.getString(R.string.audio_device_usb); + case AudioDeviceInfo.TYPE_HDMI: + case AudioDeviceInfo.TYPE_HDMI_ARC: + return context.getString(R.string.audio_device_hdmi); + case AudioDeviceInfo.TYPE_DOCK: + case AudioDeviceInfo.TYPE_AUX_LINE: + case AudioDeviceInfo.TYPE_LINE_ANALOG: + case AudioDeviceInfo.TYPE_LINE_DIGITAL: + return context.getString(R.string.audio_device_line); + case AudioDeviceInfo.TYPE_BUILTIN_MIC: + return context.getString(R.string.audio_device_builtin_mic); + default: + return typeName(type); + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/HostCameraDevices.java b/app/src/main/java/cn/classfun/droidvm/lib/data/HostCameraDevices.java new file mode 100644 index 00000000..6cbb68f3 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/HostCameraDevices.java @@ -0,0 +1,117 @@ +// 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.data; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.content.Context; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; + +import cn.classfun.droidvm.R; + +/** + * The host cameras a camera peripheral can be pinned to. + * + *

    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}.

    + */ +public final class HostCameraDevices { + private static final String TAG = "HostCameraDevices"; + + /** "let the host pick", stored when no particular camera was chosen. */ + public static final String DEFAULT_KEY = ""; + + public static final class Entry { + /** Platform camera id, stored in the VM config. */ + public final String key; + /** Human-readable name for the picker. */ + public final String label; + /** {@link CameraCharacteristics#LENS_FACING_FRONT} and friends, -1 when unknown. */ + public final int facing; + + Entry(@NonNull String key, @NonNull String label, int facing) { + this.key = key; + this.label = label; + this.facing = facing; + } + } + + private HostCameraDevices() { + } + + /** Every camera the platform reports, in its own order. Empty when CameraManager is + * unreachable, which the picker shows as "no camera on this host". */ + @NonNull + public static List list(@NonNull Context context) { + var out = new ArrayList(); + var manager = context.getSystemService(CameraManager.class); + if (manager == null) return out; + try { + for (var id : manager.getCameraIdList()) { + int facing = -1; + try { + var facingValue = manager.getCameraCharacteristics(id) + .get(CameraCharacteristics.LENS_FACING); + if (facingValue != null) facing = facingValue; + } catch (Exception e) { + // A camera the platform lists but will not describe is usually one this uid + // may not touch. Keep it in the list under its id rather than dropping it: + // the guest may still be able to open it, and a missing row looks like a bug. + Log.w(TAG, fmt("characteristics for camera %s unavailable", id), e); + } + out.add(new Entry(id, labelFor(context, id, facing), facing)); + } + } catch (Exception e) { + Log.w(TAG, "camera enumeration failed", e); + } + return out; + } + + /** The label to show for a stored key, falling back to the stored label for a camera this + * host does not have. */ + @NonNull + public static String labelOf(@NonNull Context context, @Nullable String key, + @NonNull String storedLabel) { + if (key == null || key.isEmpty()) return context.getString(R.string.host_camera_default); + for (var entry : list(context)) { + if (entry.key.equals(key)) return entry.label; + } + return storedLabel.isEmpty() ? key : storedLabel; + } + + @NonNull + private static String labelFor(@NonNull Context context, @NonNull String id, int facing) { + int nameId; + switch (facing) { + case CameraCharacteristics.LENS_FACING_FRONT: + nameId = R.string.host_camera_front; + break; + case CameraCharacteristics.LENS_FACING_BACK: + nameId = R.string.host_camera_back; + break; + case CameraCharacteristics.LENS_FACING_EXTERNAL: + nameId = R.string.host_camera_external; + break; + default: + return context.getString(R.string.host_camera_unknown, id); + } + return context.getString(nameId, id); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/HostKernel.java b/app/src/main/java/cn/classfun/droidvm/lib/data/HostKernel.java new file mode 100644 index 00000000..44659812 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/HostKernel.java @@ -0,0 +1,68 @@ +// 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.data; + +import static cn.classfun.droidvm.lib.utils.RunUtils.runList; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.regex.Pattern; + +/** + * Which kernel this phone is running, to the major.minor that behaviour actually turns on. + * + *

    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.

    + */ +public final class HostKernel { + private static final Pattern MAJOR_MINOR = Pattern.compile("^(\\d+\\.\\d+)"); + /** The 6.1 GKI. Named because several rules key off it and a bare "6.1" reads as nothing. */ + public static final String GKI_6_1 = "6.1"; + + @Nullable + private static volatile String cached; + + private HostKernel() { + } + + /** + * The running kernel's {@code major.minor}, or null when {@code uname} could not be read. + * + *

    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.

    + */ + @Nullable + public static String majorMinor() { + var known = cached; + if (known != null) return known; + String release; + try { + release = runList("uname", "-r").getOutString().trim(); + } catch (Exception e) { + return null; + } + var parsed = majorMinorOf(release); + cached = parsed; + return parsed; + } + + /** {@link #majorMinor} for a {@code uname -r} string already in hand. Pure. */ + @Nullable + public static String majorMinorOf(@Nullable String unameRelease) { + if (unameRelease == null) return null; + var m = MAJOR_MINOR.matcher(unameRelease.trim()); + return m.find() ? m.group(1) : null; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/Images.java b/app/src/main/java/cn/classfun/droidvm/lib/data/Images.java index 29985ee4..d5c84de1 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/data/Images.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/Images.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.data; import static android.os.Build.SUPPORTED_ABIS; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/Language.java b/app/src/main/java/cn/classfun/droidvm/lib/data/Language.java index 1305c2a6..bd62411a 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/data/Language.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/Language.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.data; import static cn.classfun.droidvm.lib.utils.AssetUtils.loadYAMLFromAssets; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/License.java b/app/src/main/java/cn/classfun/droidvm/lib/data/License.java index 51726f2a..2bae82c7 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/data/License.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/License.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.data; import static cn.classfun.droidvm.lib.utils.AssetUtils.loadYAMLFromAssets; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/QcomChipName.java b/app/src/main/java/cn/classfun/droidvm/lib/data/QcomChipName.java index 851f814e..13549039 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/data/QcomChipName.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/QcomChipName.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.data; import static cn.classfun.droidvm.lib.utils.RunUtils.runListQuiet; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/QcomGunyahSupports.java b/app/src/main/java/cn/classfun/droidvm/lib/data/QcomGunyahSupports.java index e1a5fff3..f15f0661 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/data/QcomGunyahSupports.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/QcomGunyahSupports.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.data; import static cn.classfun.droidvm.lib.utils.AssetUtils.loadYAMLFromAssets; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/Repos.java b/app/src/main/java/cn/classfun/droidvm/lib/data/Repos.java index bf7577f8..c31177db 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/data/Repos.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/Repos.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.data; import static java.util.Objects.requireNonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/data/SocIdentity.java b/app/src/main/java/cn/classfun/droidvm/lib/data/SocIdentity.java new file mode 100644 index 00000000..7d7d5c82 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/data/SocIdentity.java @@ -0,0 +1,129 @@ +// 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.data; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import static cn.classfun.droidvm.lib.utils.RunUtils.runListQuiet; + +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.Locale; + +/** + * Who made this device's SoC, as a stable token, plus its model string. + * + *

    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.

    + */ + public void observe(@NonNull UUID vmId, @NonNull String stream, @NonNull String text) { + } + + /** + * Called when [vmId]'s log context is dropped, which is when that VM exits. + * + *

    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.

    + */ + public void onLogContextReset(@NonNull UUID vmId) { + } + public boolean isOnce() { return true; } @@ -48,14 +76,54 @@ protected static void showDialog( @StringRes int titleId, @StringRes int messageId, Object... args + ) { + showDialog(ctx, urlId, titleId, ctx.getString(messageId, args), 0, null); + } + + /** + * The same dialog with the message already built, and one extra action beside OK and the wiki + * link. A message that lists what the VM actually did is not one format string, which is why + * this takes the text rather than a resource id. + * + *

    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.

    + */ + protected static void showDialog( + @NonNull Context ctx, + @StringRes int urlId, + @StringRes int titleId, + @NonNull CharSequence message, + @StringRes int actionId, + @Nullable OnClickListener action ) { var mab = new MaterialAlertDialogBuilder(ctx); mab.setTitle(titleId); - mab.setMessage(ctx.getString(messageId, args)); + mab.setMessage(message); mab.setPositiveButton(android.R.string.ok, null); + if (actionId != 0) mab.setNegativeButton(actionId, action); var url = ctx.getString(urlId); OnClickListener cb = (d, w) -> ctx.startActivity(new Intent(ACTION_VIEW, Uri.parse(url))); if (!url.isEmpty()) mab.setNeutralButton(R.string.log_helper_open_url, cb); - mab.show(); + var dialog = mab.show(); + TextView body = dialog.findViewById(android.R.id.message); + if (body == null) return; + // A message built from HTML anchors already carries its URLSpans, and Linkify would strip + // them while hunting for raw URLs -- so anchors get the movement method only, and plain + // text keeps the old raw-URL pass. + boolean hasAnchors = message instanceof android.text.Spanned + && ((android.text.Spanned) message) + .getSpans(0, message.length(), android.text.style.URLSpan.class).length > 0; + if (hasAnchors) { + body.setMovementMethod(android.text.method.LinkMovementMethod.getInstance()); + } else { + Linkify.addLinks(body, Linkify.WEB_URLS); + } } } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/BadSM8650HostKernelHandler.java b/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/BadSM8650HostKernelHandler.java index 31f22079..a38248bf 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/BadSM8650HostKernelHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/BadSM8650HostKernelHandler.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.handler; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/HugePageFaultHandler.java b/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/HugePageFaultHandler.java index cc69844f..7bc35bed 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/HugePageFaultHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/HugePageFaultHandler.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.handler; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/OsKernelWithoutRestrictPoolHandler.java b/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/OsKernelWithoutRestrictPoolHandler.java index 5316f484..0f8655d6 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/OsKernelWithoutRestrictPoolHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/OsKernelWithoutRestrictPoolHandler.java @@ -1,29 +1,128 @@ +// 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.handler; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + import android.content.Context; +import android.content.Intent; import androidx.annotation.NonNull; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.diag.LogHelperHandler; +import cn.classfun.droidvm.ui.vm.console.VMConsoleActivity; +/** + * A device could not reach memory the guest lent to the host in a protected VM. + * + *

    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.

    + */ public final class OsKernelWithoutRestrictPoolHandler extends LogHelperHandler { + /** + * What the log page is prefiltered with -- the marker without the address, since the address + * differs per line. {@link #MARKER} is the same text plus the start of the address, which is + * what makes a line one of these rather than a mention of the words. + */ + private static final String FILTER = "host access to lent memory region at"; + private static final String MARKER = fmt("%s 0x", FILTER); + /** + * The failing device, which crosvm puts before " activate failed" on the same line: + * {@code ... virtio_pci_device] pcivu-sound activate failed: failed to get host address: host + * access to lent memory region at 0x105600000 (purpose=GuestMemoryRegion) in protected VM} + */ + private static final Pattern DEVICE = Pattern.compile("(\\S+) activate failed"); + private static final String BULLET = "\u2022 "; // U+2022, escaped to keep this file ASCII + private static final String STREAM = "stderr"; + + /** + * Distinct device names per VM, in the order the log named them. Keyed by vmId because one + * instance serves every VM, and emptied by {@link #onLogContextReset}. + */ + private final Map> devices = new ConcurrentHashMap<>(); + + @Override + public void observe(@NonNull UUID vmId, @NonNull String stream, @NonNull String text) { + if (!stream.equals(STREAM) || !text.contains(MARKER)) return; + var found = devices.computeIfAbsent( + vmId, k -> Collections.synchronizedSet(new LinkedHashSet<>())); + for (var line : text.split("\n")) { + if (!line.contains(MARKER)) continue; + var m = DEVICE.matcher(line); + // A matching line that does not name a device goes in as it came: dropping it would + // report fewer devices than the log shows, which is the one thing this list is for. + found.add(m.find() ? m.group(1) : line.trim()); + } + } + @Override public boolean match(@NonNull UUID vmId, @NonNull String stream, @NonNull String text) { - return - stream.equals("stderr") && - text.contains("host access to lent memory region at 0x"); + // observe() has already read this same text; a non-empty list is exactly "a line with the + // marker was seen", so the buffer is not scanned a second time. + return stream.equals(STREAM) && !namesOf(vmId).isEmpty(); + } + + @Override + public void onLogContextReset(@NonNull UUID vmId) { + devices.remove(vmId); } @Override public void show(@NonNull Context ctx, @NonNull UUID vmId, @NonNull String vmName) { + var sb = new android.text.SpannableStringBuilder(); + sb.append(ctx.getString(R.string.log_helper_no_restrict_pool_devices, vmName)); + for (var device : namesOf(vmId)) sb.append('\n').append(BULLET).append(device); + sb.append("\n\n"); + // The body carries its links as anchors with human labels, so it is HTML in the resource + // and spans here; a raw URL would be linkified downstream, but an tag would not. + sb.append(android.text.Html.fromHtml( + ctx.getString(R.string.log_helper_no_restrict_pool_message), + android.text.Html.FROM_HTML_MODE_LEGACY)); showDialog(ctx, R.string.log_helper_no_restrict_pool_url, R.string.log_helper_no_restrict_pool_title, - R.string.log_helper_no_restrict_pool_message, - vmName + sb, + R.string.log_helper_open_log, + (d, w) -> openLog(ctx, vmId, vmName) ); } + + /** A snapshot: observe() runs on the daemon's event thread and show() on the main one. */ + @NonNull + private List namesOf(@NonNull UUID vmId) { + var found = devices.get(vmId); + if (found == null) return List.of(); + synchronized (found) { + return new ArrayList<>(found); + } + } + + /** + * The log page, on stderr, prefiltered to the lines the list above was read from -- the point + * of opening it here is to see those lines, not to land in the whole boot log. + */ + private static void openLog(@NonNull Context ctx, @NonNull UUID vmId, @NonNull String vmName) { + var intent = new Intent(ctx, VMConsoleActivity.class); + intent.putExtra(VMConsoleActivity.EXTRA_VM_ID, vmId.toString()); + intent.putExtra(VMConsoleActivity.EXTRA_VM_NAME, vmName); + intent.putExtra(VMConsoleActivity.EXTRA_STREAM, STREAM); + intent.putExtra(VMConsoleActivity.EXTRA_LOGS, true); + intent.putExtra(VMConsoleActivity.EXTRA_FILTER, FILTER); + ctx.startActivity(intent); + } } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/UnsupportedGunyahVersionHandler.java b/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/UnsupportedGunyahVersionHandler.java index 8c55a0d6..72e1147c 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/UnsupportedGunyahVersionHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/UnsupportedGunyahVersionHandler.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.handler; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/UnsupportedSandboxHandler.java b/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/UnsupportedSandboxHandler.java index 8a870273..85c64734 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/UnsupportedSandboxHandler.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/diag/handler/UnsupportedSandboxHandler.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.handler; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/download/DiskDownloadManager.java b/app/src/main/java/cn/classfun/droidvm/lib/download/DiskDownloadManager.java index 0d2ff6b2..3758fd0b 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/download/DiskDownloadManager.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/download/DiskDownloadManager.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.download; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -60,6 +63,7 @@ public final class DiskDownloadManager { private static final AtomicLong NEXT_ID = new AtomicLong(1); private static final ConcurrentHashMap JOBS = new ConcurrentHashMap<>(); + private static final java.util.Set RETAINED = ConcurrentHashMap.newKeySet(); private DiskDownloadManager() { } @@ -124,7 +128,9 @@ public static final class Result { /** * Registers a download and returns its id. Drops any earlier job for the same - * destination and purges finished jobs. The caller then starts + * destination and purges failed/cancelled jobs. Successful jobs stay available + * until their source Activity consumes them, so a completed background download + * can still continue its import workflow. The caller then starts * {@link DiskDownloadService} for this id, which runs the download. */ public static long enqueue( @@ -185,6 +191,31 @@ public static Result getResult(long id) { return new Result(d.folder, d.name, d.diskId); } + /** + * Keeps a successful background result until its source screen has resumed and + * consumed it. This is opt-in so ordinary one-shot download screens retain + * their existing cleanup behaviour. + */ + public static void retainUntilReleased(long id) { + if (JOBS.containsKey(id)) RETAINED.add(id); + } + + /** Atomically consumes a successful result so two Activity instances cannot process it twice. */ + @Nullable + public static Result consumeResult(long id) { + var d = JOBS.get(id); + if (d == null || d.state != STATE_SUCCESS || !JOBS.remove(id, d)) return null; + RETAINED.remove(id); + return new Result(d.folder, d.name, d.diskId); + } + + /** Releases a terminal job after its source Activity has consumed the result. */ + public static void release(long id) { + RETAINED.remove(id); + var d = JOBS.get(id); + if (d != null && (d.cancelled.get() || isTerminal(id))) JOBS.remove(id, d); + } + /** Ids of all known (in-flight or just-finished) downloads. */ @NonNull public static long[] activeIds() { @@ -441,15 +472,19 @@ private static void copyFile(File src, File dest) throws IOException { } } - /** Drops finished jobs and any earlier job aimed at the same destination. */ + /** Drops failed/cancelled jobs and any earlier job aimed at the same destination. */ private static void purgeFinishedAndDuplicates(String folder, String name) { for (var e : JOBS.entrySet()) { var d = e.getValue(); boolean duplicate = d.folder.equals(folder) && d.name.equals(name); - boolean finished = d.state == STATE_SUCCESS + boolean terminal = d.state == STATE_SUCCESS || d.state == STATE_FAILED || d.state == STATE_CANCELLED; + boolean finished = terminal && !RETAINED.contains(d.id); if (duplicate) d.cancelled.set(true); - if (duplicate || finished) JOBS.remove(e.getKey()); + if (duplicate || finished) { + JOBS.remove(e.getKey()); + RETAINED.remove(e.getKey()); + } } } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/download/DiskDownloadService.java b/app/src/main/java/cn/classfun/droidvm/lib/download/DiskDownloadService.java index 6ea6a702..494c8c20 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/download/DiskDownloadService.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/download/DiskDownloadService.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.download; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/hugepage/PoolPreflight.java b/app/src/main/java/cn/classfun/droidvm/lib/hugepage/PoolPreflight.java new file mode 100644 index 00000000..0289d5c2 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/hugepage/PoolPreflight.java @@ -0,0 +1,260 @@ +// 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.hugepage; + +import static cn.classfun.droidvm.lib.utils.FileUtils.shellCheckExists; +import static cn.classfun.droidvm.lib.utils.FileUtils.shellReadFile; +import static cn.classfun.droidvm.lib.utils.RunUtils.run; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; +import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum; + +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.function.BooleanSupplier; + +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.vm.GuestPoolSizing; +import cn.classfun.droidvm.lib.store.vm.VMBackend; +import cn.classfun.droidvm.lib.store.vm.VMHypervisor; + +/** + * Is the huge-page reserve able to back this VM right now? + * + *

    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.

    + */ + public static boolean appliesTo(@NonNull DataItem item) { + var backend = optEnum(item, "backend", VMBackend.DEFAULT); + var configured = optEnum(item, "hypervisor", VMHypervisor.DEFAULT); + return VMHypervisor.resolveConfigured(backend, configured) == VMHypervisor.GUNYAH; + } + + /** Reads the reserve and sizes this VM against it. Never throws. */ + @NonNull + public static Status check(@NonNull DataItem item) { + if (!appliesTo(item)) + return new Status(false, 0, 0); + long avail = readPages("pool_avail", -1); + if (avail < 0) + return new Status(false, 0, 0); + return new Status(true, avail, neededPages(item)); + } + + /** + * The 2 MB pages this VM's boot-time regions will take out of the reserve. + * + *

    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.

    + */ + public static boolean waitForPool(@NonNull DataItem item, int attempts, long sleepMs, + int acquireAt, @NonNull BooleanSupplier abort) { + var status = check(item); + if (!status.applicable || status.isEnough()) + return true; + Log.i(TAG, fmt("waiting for the huge-page reserve: %s", status)); + for (int i = 1; i <= attempts; i++) { + if (abort.getAsBoolean()) { + Log.i(TAG, fmt("the wait for the reserve was called off after %d attempt(s)", i - 1)); + return false; + } + if (i == acquireAt) { + Log.i(TAG, fmt("reserve still short at attempt %d; asking it to acquire", i)); + acquire(2); + } + try { + Thread.sleep(sleepMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + status = check(item); + if (status.isEnough()) { + Log.i(TAG, fmt("reserve recovered after %d attempt(s): %s", i, status)); + return true; + } + } + Log.w(TAG, fmt("reserve still short after %d attempt(s): %s -- starting anyway", + attempts, status)); + return false; + } + + /** + * Asks the module to grow the reserve ({@code acquire=}), falling back to the older + * {@code manual_refill} knob. Best-effort: the caller carries on either way. + */ + public static boolean acquire(int mode) { + if (writeKnob("acquire", Integer.toString(mode))) + return true; + return writeKnob("manual_refill", "1"); + } + + private static boolean writeKnob(@NonNull String knob, @NonNull String value) { + var path = pathJoin(SYSFS_PARAMS, knob); + if (!shellCheckExists(path)) + return false; + return run("echo %s > %s", value, path).isSuccess(); + } + + private static long readPages(@NonNull String knob, long fallback) { + var path = pathJoin(SYSFS_PARAMS, knob); + try { + if (!shellCheckExists(path)) + return fallback; + return Long.parseLong(shellReadFile(path).trim()); + } catch (Exception e) { + return fallback; + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/natives/NativeProcess.java b/app/src/main/java/cn/classfun/droidvm/lib/natives/NativeProcess.java index 9d43a59c..9781a3c8 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/natives/NativeProcess.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/natives/NativeProcess.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.natives; import static java.lang.Thread.sleep; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/natives/UnixHelper.java b/app/src/main/java/cn/classfun/droidvm/lib/natives/UnixHelper.java index ce361676..787f0720 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/natives/UnixHelper.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/natives/UnixHelper.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.natives; import static cn.classfun.droidvm.lib.Constants.DATA_DIR; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/natives/VulkanBlitProbe.java b/app/src/main/java/cn/classfun/droidvm/lib/natives/VulkanBlitProbe.java new file mode 100644 index 00000000..23ea627d --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/natives/VulkanBlitProbe.java @@ -0,0 +1,64 @@ +// 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.natives; + +import androidx.annotation.Nullable; + +/** + * Probes the platform's stock Vulkan driver for the extensions the native-display GPU blit needs. + * + *

    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 suppressed = new ArrayList<>(); + + private SystemGestureGuard() { + } + + /** Turns the OEM gestures off; call when the VM display becomes foreground. */ + public static void enterDisplay() { + executor.execute(() -> { + RunContext shell = RootRunContext.getContext(); + synchronized (suppressed) { + // A re-enter without exit (activity recreation) must not re-read "0" as the + // value to restore, so the restore list only grows from a clean slate. + if (!suppressed.isEmpty()) return; + for (String key : KEYS) { + RunResult get = shell.runQuiet(fmt("settings get system %s", key)); + if (!get.isSuccess() || !"1".equals(get.getOutString())) continue; + if (shell.runQuiet(fmt("settings put system %s 0", key)).isSuccess()) { + suppressed.add(key); + Log.i(TAG, fmt("suppressed host gesture: %s", key)); + } + } + } + }); + } + + /** Restores whatever {@link #enterDisplay} turned off; call when the display leaves. */ + public static void exitDisplay() { + executor.execute(() -> { + RunContext shell = RootRunContext.getContext(); + synchronized (suppressed) { + for (String key : suppressed) { + shell.runQuiet(fmt("settings put system %s 1", key)); + Log.i(TAG, fmt("restored host gesture: %s", key)); + } + suppressed.clear(); + } + }); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/peripheral/PeripheralForegroundService.java b/app/src/main/java/cn/classfun/droidvm/lib/peripheral/PeripheralForegroundService.java new file mode 100644 index 00000000..546b4e2d --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/peripheral/PeripheralForegroundService.java @@ -0,0 +1,128 @@ +// 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.peripheral; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.IBinder; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.ui.main.MainActivity; + +/** + * Holds the app's uid in a foreground state while a running VM carries a peripheral that needs it. + * + *

    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.

    + */ +public final class PeripheralForegroundService extends Service { + private static final String TAG = "PeripheralFgs"; + private static final String CHANNEL_ID = "peripheral_foreground"; + private static final int NOTIF_ID = 0x45_00_00_01; + private static final String EXTRA_TYPES = "types"; + + /** + * Brings the service in line with {@code typeMask}: starts or re-types it when non-zero, + * stops it when zero. Safe to call with the value it already has. + * + *

    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.

    + */ + public static void apply(@NonNull Context context, int typeMask) { + var intent = new Intent(context, PeripheralForegroundService.class); + if (typeMask == 0) { + context.stopService(intent); + return; + } + intent.putExtra(EXTRA_TYPES, typeMask); + try { + context.startForegroundService(intent); + } catch (Exception e) { + // Background-start restrictions, or a missing FOREGROUND_SERVICE_* permission. The VM + // still runs; only the peripheral that wanted this is affected, and it will report its + // own failure to open. + Log.w(TAG, "could not raise the peripheral foreground service", e); + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public int onStartCommand(@Nullable Intent intent, int flags, int startId) { + int typeMask = intent == null ? 0 : intent.getIntExtra(EXTRA_TYPES, 0); + if (typeMask == 0) { + stopSelf(); + return START_NOT_STICKY; + } + ensureChannel(); + try { + startForeground(NOTIF_ID, buildNotification(), typeMask); + } catch (Exception e) { + // startForeground with a typed service throws when the matching runtime permission is + // not held -- the user declined CAMERA after the peripheral was added, say. Stopping + // is the honest outcome: a service that cannot carry the type it was raised for grants + // no capability, and leaving it up would show a notification that promises otherwise. + Log.w(TAG, fmt("startForeground rejected for types 0x%s", + Integer.toHexString(typeMask)), e); + stopSelf(); + return START_NOT_STICKY; + } + // Not sticky: the policy re-applies from the live VM states, so a restart by the system + // with no VM running would raise a service nothing asked for. + return START_NOT_STICKY; + } + + @NonNull + private android.app.Notification buildNotification() { + var open = PendingIntent.getActivity(this, 0, new Intent(this, MainActivity.class), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); + return new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.ic_camera) + .setContentTitle(getString(R.string.peripheral_fgs_title)) + .setContentText(getString(R.string.peripheral_fgs_text)) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setContentIntent(open) + .build(); + } + + private void ensureChannel() { + var nm = getSystemService(NotificationManager.class); + if (nm == null) return; + var channel = new NotificationChannel(CHANNEL_ID, + getString(R.string.notif_channel_peripheral_foreground), + NotificationManager.IMPORTANCE_LOW); + channel.setDescription(getString(R.string.notif_channel_peripheral_foreground_desc)); + channel.setShowBadge(false); + nm.createNotificationChannel(channel); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/BootFile.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/BootFile.java index 70ece34e..8b22d0d1 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/pkg/BootFile.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/BootFile.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 androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskChainPlan.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskChainPlan.java new file mode 100644 index 00000000..8bf5393e --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskChainPlan.java @@ -0,0 +1,171 @@ +// 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.utils.StringUtils.basename; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.safeFileName; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Which files a package must carry for one VM's disks. A qcow2 overlay is only half a disk: the + * guest reads its backing image too, so a package holding the overlay alone imports as a VM that + * cannot boot - the copied header still names a path that exists only on the machine it came + * from. This walks each selected disk down its backing chain and returns every file involved, + * child before parent, with: + *
      + *
    • one entry per file, so two disks sharing a base image ship it once;
    • + *
    • unique archive names, so two disks named {@code disk.qcow2} in different folders do not + * collide inside the tar (they did before backing images made that likely);
    • + *
    • the parent link recorded as an archive name, which is what the importer can act on - + * the on-disk path is meaningless once the files land somewhere else.
    • + *
    + * + *

    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 build( + @NonNull List tops, + @NonNull BackingLookup lookup + ) throws Exception { + var order = new ArrayList(); + var byPath = new HashMap(); + var archives = new HashSet(); + for (var top : tops) { + if (top.path == null || top.path.isEmpty()) continue; + var known = byPath.get(top.path); + Member member; + if (known == null) { + member = add(order, byPath, archives, top.path); + } else if (known.attachment == null) { + member = known; // already packed as a backing image; it is a disk of its own too + } else { + // The same file in two slots. Give the second slot its own copy rather than + // dropping it: a package that silently loses a disk is worse than a duplicate. + member = new Member(top.path, uniqueArchive(archives, basename(top.path))); + order.add(member); + } + member.attachment = top; + walkUp(order, byPath, archives, member, lookup); + } + return order; + } + + /** Follow {@code start}'s backing chain upward, adding each file it reaches. */ + private static void walkUp( + @NonNull List order, + @NonNull Map byPath, + @NonNull Set archives, + @NonNull Member start, + @NonNull BackingLookup lookup + ) throws Exception { + var seen = new HashSet(); + seen.add(start.path); + var child = start; + for (int depth = 0; depth < MAX_CHAIN; depth++) { + var backing = lookup.backingOf(child.path); + if (backing == null || backing.isEmpty()) return; + if (!seen.add(backing)) throw new IOException(fmt( + "backing chain of %s loops at %s", basename(start.path), backing + )); + var known = byPath.get(backing); + var parent = known != null ? known : add(order, byPath, archives, backing); + child.backingArchive = parent.archivePath; + // A file already in the plan brought its own parents with it when it was added, + // so there is nothing above this point left to walk. + if (known != null) return; + child = parent; + } + throw new IOException(fmt( + "backing chain of %s is deeper than %d images", basename(start.path), MAX_CHAIN + )); + } + + @NonNull + private static Member add( + @NonNull List order, + @NonNull Map byPath, + @NonNull Set archives, + @NonNull String path + ) { + var member = new Member(path, uniqueArchive(archives, basename(path))); + order.add(member); + byPath.put(path, member); + return member; + } + + /** {@code name} as an archive entry name no other member has taken. */ + @NonNull + private static String uniqueArchive(@NonNull Set taken, @NonNull String name) { + var base = safeFileName(name, "disk.img"); + if (taken.add(base)) return base; + int dot = base.lastIndexOf('.'); + var stem = dot > 0 ? base.substring(0, dot) : base; + var ext = dot > 0 ? base.substring(dot) : ""; + for (int i = 1; ; i++) { + var candidate = fmt("%s_%d%s", stem, i, ext); + if (taken.add(candidate)) return candidate; + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskEntry.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskEntry.java index 0017d0e6..933c1701 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskEntry.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskEntry.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; @@ -8,16 +11,29 @@ import org.json.JSONObject; import java.io.File; +import java.util.Set; import cn.classfun.droidvm.lib.store.base.JSONSerialize; import cn.classfun.droidvm.ui.disk.create.DiskFormat; -public final class DiskEntry implements JSONSerialize { +public final class DiskEntry implements JSONSerialize, ManifestFeature.Carrier { + /** {@link DiskRef#index} of a file that fills no VM disk slot. */ + public static final int INDEX_BACKING = -1; + public DiskRef ref; public String name = null; public DiskFormat format = null; public long size = 0; public String archivePath = null; + /** + * Whether this file fills a VM disk slot. False for a file the package carries only because + * another one backs onto it: the importer has to restore it, but must not hand it to the + * guest as a disk of its own. Absent in packages written before backing chains were packed, + * where every entry was a disk, hence the default. + */ + public boolean attached = true; + /** {@link #archivePath} of this file's backing image inside the package, or "". */ + public String backingArchive = ""; public File target = null; public DiskEntry(DiskRef ref) { @@ -25,11 +41,13 @@ public DiskEntry(DiskRef ref) { } public DiskEntry(@NonNull JSONObject o) { - ref = new DiskRef(0, o); + ref = new DiskRef(o.optInt("index", 0), o); name = o.optString("name", "disk.img"); format = optEnum(o, "format", DiskFormat.RAW); size = o.optLong("size"); archivePath = o.optString("archive_path"); + attached = o.optBoolean("attached", true); + backingArchive = o.optString("backing_archive", ""); } @NonNull @@ -45,27 +63,37 @@ public JSONObject toJson() throws JSONException { o.put("format", format.name().toLowerCase()); o.put("size", size); o.put("archive_path", archivePath); + o.put("attached", attached); + if (!backingArchive.isEmpty()) o.put("backing_archive", backingArchive); return o; } - private static @NonNull String sanitize(@NonNull String name) { - var sb = new StringBuilder(); - for (int i = 0; i < name.length(); i++) { - char c = name.charAt(i); - if (c == '/' || c == '\\' || c < 0x20) c = '_'; - sb.append(c); - } - return sb.toString(); + @Override + public void collectFeatures(@NonNull Set into) { + if (!attached || !backingArchive.isEmpty()) into.add(ManifestFeature.BACKING_CHAIN); } - public void build() { + public void build(@NonNull String archivePath) { var file = new File(ref.path); name = file.getName(); - archivePath = sanitize(name); + this.archivePath = archivePath; format = DiskFormat.fromFilename(name); size = file.length(); } + /** The manifest entry for one file of a {@link DiskChainPlan}. */ + @NonNull + public static DiskEntry of(@NonNull DiskChainPlan.Member member) { + var attachment = member.attachment; + var entry = new DiskEntry(attachment != null + ? attachment + : new DiskRef(INDEX_BACKING, member.path)); + entry.attached = attachment != null; + entry.backingArchive = member.backingArchive; + entry.build(member.archivePath); + return entry; + } + @NonNull public static DiskEntry from(Object o) throws JSONException { if (o instanceof JSONObject) diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskRef.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskRef.java index 768b9876..37adca62 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskRef.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/DiskRef.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.util.Objects.requireNonNull; @@ -26,6 +29,14 @@ public DiskRef(int index, @NonNull JSONObject jo) { this.bus = optEnum(jo, "bus", DiskBus.VIRTIO); } + /** A file with no VM disk slot of its own - a backing image the package carries. */ + public DiskRef(int index, @NonNull String path) { + this.index = index; + this.path = path; + this.readonly = true; + this.bus = DiskBus.VIRTIO; + } + public DiskRef(int index, @NonNull DataItem o) { this.index = index; this.path = o.optString("path", ""); @@ -46,6 +57,7 @@ public boolean isCDROM() { @Override public JSONObject toJson() throws JSONException { var d = new JSONObject(); + d.put("index", index); d.put("readonly", readonly); d.put("bus", bus); d.put("path", path); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/ManifestFeature.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/ManifestFeature.java new file mode 100644 index 00000000..981330d6 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/ManifestFeature.java @@ -0,0 +1,53 @@ +// 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 androidx.annotation.NonNull; + +import java.util.EnumSet; +import java.util.Set; + +/** + * Everything a package can contain that a reader from before it would misread, each with the + * manifest version that introduced it. A package is stamped with the highest {@link #since} among + * the features it actually uses ({@link #versionFor}), so one that uses none stays readable by + * every build that ever wrote a package, and what a build can read is simply {@link #latest()}. + * + *

    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 into); + } + + /** The version a package using exactly {@code used} must be stamped with. */ + public static int versionFor(@NonNull Set used) { + int version = PackageConstants.MANIFEST_VERSION_BASE; + for (var feature : used) version = Math.max(version, feature.since); + return version; + } + + /** The newest version any feature needs: the most a build with this list can read. */ + public static int latest() { + return versionFor(EnumSet.allOf(ManifestFeature.class)); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/NetworkImportPlan.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/NetworkImportPlan.java new file mode 100644 index 00000000..432f01dd --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/NetworkImportPlan.java @@ -0,0 +1,347 @@ +// 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.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import cn.classfun.droidvm.lib.network.IPv4Network; +import cn.classfun.droidvm.lib.network.IPv6Network; +import cn.classfun.droidvm.lib.store.base.DataStore; +import cn.classfun.droidvm.lib.store.network.NetworkConfig; +import cn.classfun.droidvm.lib.store.network.NetworkConfigValidator; +import cn.classfun.droidvm.lib.store.network.NetworkConflicts; +import cn.classfun.droidvm.lib.store.network.UplinkMode; + +/** + * What happens to each network a package carries: join one this phone already has, create it, or + * leave it behind. One instance answers for one import against one set of existing networks, and + * remembers the names its own creations have claimed, so two packaged networks that want the same + * name do not both end up asking for it. + * + *

    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 existing; + private final Set takenNames = new HashSet<>(); + private final Set takenBridges = new HashSet<>(); + private final Set takenIds = new HashSet<>(); + + public NetworkImportPlan(@NonNull DataStore store) { + this(NetworkConflicts.snapshot(store)); + } + + public NetworkImportPlan(@NonNull List existing) { + this.existing = existing; + for (var net : existing) { + var name = net.getName(); + if (name != null) takenNames.add(name); + var bridge = net.getBridgeName(); + if (bridge != null && !bridge.isEmpty()) takenBridges.add(bridge); + var id = net.item.optString("id", ""); + if (!id.isEmpty()) takenIds.add(id); + } + } + + /** What to do with one packaged network. */ + public enum Action { + JOIN("join"), + CREATE("create"), + SKIP("skip"); + + private final String key; + + Action(@NonNull String key) { + this.key = key; + } + + @NonNull + public String key() { + return key; + } + + /** The action for a wire key; unknown keys read as {@link #CREATE}, the old default. */ + @NonNull + public static Action fromKey(@Nullable String key) { + for (var v : values()) + if (v.key.equals(key)) return v; + return CREATE; + } + } + + /** One decision, keyed by the packaged network's {@link #REF_KEY}. */ + public static final class Entry { + @NonNull + public final String ref; + @NonNull + public final Action action; + /** The network to join, for {@link Action#JOIN}. */ + @Nullable + public final String networkId; + /** The network to create, for {@link Action#CREATE}; null to derive it on the spot. */ + @Nullable + public final NetworkConfig config; + + public Entry( + @NonNull String ref, + @NonNull Action action, + @Nullable String networkId, + @Nullable NetworkConfig config + ) { + this.ref = ref; + this.action = action; + this.networkId = networkId; + this.config = config; + } + + @NonNull + public JSONObject toJson() throws JSONException { + var o = new JSONObject(); + o.put("ref", ref); + o.put("action", action.key()); + if (networkId != null) o.put("network_id", networkId); + if (config != null) o.put("config", config.toJson()); + return o; + } + + @NonNull + public static Entry fromJson(@NonNull JSONObject o) throws JSONException { + var cfgJson = o.optJSONObject("config"); + NetworkConfig cfg = null; + if (cfgJson != null) cfg = new NetworkConfig(cfgJson); + var id = o.optString("network_id", ""); + return new Entry( + o.optString("ref", ""), + Action.fromKey(o.optString("action", "")), + id.isEmpty() ? null : id, + cfg + ); + } + } + + /** Reads a plan array; entries that cannot be parsed are dropped rather than failing it. */ + @NonNull + public static List parse(@Nullable JSONArray arr) { + var out = new ArrayList(); + if (arr == null) return out; + for (int i = 0; i < arr.length(); i++) { + var o = arr.optJSONObject(i); + if (o == null) continue; + try { + var entry = Entry.fromJson(o); + if (!entry.ref.isEmpty()) out.add(entry); + } catch (JSONException ignored) { + } + } + return out; + } + + /** The entry for a ref, or null when the plan says nothing about it. */ + @Nullable + public static Entry findRef(@NonNull List plan, @NonNull String ref) { + for (var entry : plan) + if (entry.ref.equals(ref)) return entry; + return null; + } + + /** + * The networks this packaged one may be joined to, closest match first. + * + *

    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 candidates(@NonNull NetworkConfig packaged) { + var out = new ArrayList(); + for (var net : existing) + if (NetworkConflicts.sameKind(packaged, net)) out.add(net); + out.sort(Comparator + .comparingLong((NetworkConfig net) -> distance(packaged, net)) + .thenComparing(net -> net.getName() == null ? "" : net.getName())); + return out; + } + + /** + * Why creating this packaged network here would collide, or null when it would not. Same + * rules as the network editor: an overlapping prefix on a network of the same bridge type, + * or an L2 uplink another network already bridges. + */ + @Nullable + public NetworkConflicts.Conflict createConflict(@NonNull NetworkConfig packaged) { + return NetworkConflicts.find(packaged, existing, null); + } + + /** + * The network a packaged one would be created as: its own config, given a fresh id and + * whatever name and bridge name are still free. Names are the one thing an import is allowed + * to change quietly -- they must be unique across every network on the phone whatever its + * kind, and a collision there says nothing about whether the network itself fits. + * + *

    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 features() { + var used = EnumSet.noneOf(ManifestFeature.class); + for (var disk : disks) disk.collectFeatures(used); + return used; + } + + /** + * The oldest manifest version that can describe this package - the highest any feature in + * it needs - so a plain one-disk export stays importable by builds that predate the rest. + * Ask only once everything is collected: the answer is a function of all of it. + */ + public int resolveVersion() { + return ManifestFeature.versionFor(features()); + } + @Nullable public DiskEntry findDisk(@NonNull String archivePath) { for (var disk : disks) diff --git a/app/src/main/java/cn/classfun/droidvm/lib/pkg/Phase.java b/app/src/main/java/cn/classfun/droidvm/lib/pkg/Phase.java index 41646fe3..ce3400d6 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/pkg/Phase.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/pkg/Phase.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; public enum Phase { diff --git a/app/src/main/java/cn/classfun/droidvm/lib/run/RunContext.java b/app/src/main/java/cn/classfun/droidvm/lib/run/RunContext.java index 86c80644..2ede507e 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/run/RunContext.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/run/RunContext.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.run; import static cn.classfun.droidvm.lib.utils.RunUtils.escapedString; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/run/RunResult.java b/app/src/main/java/cn/classfun/droidvm/lib/run/RunResult.java index 4c42bfdf..f95a1a68 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/run/RunResult.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/run/RunResult.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.run; import android.util.Log; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/run/root/RootRunContext.java b/app/src/main/java/cn/classfun/droidvm/lib/run/root/RootRunContext.java index 9fd450b1..d9f15691 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/run/root/RootRunContext.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/run/root/RootRunContext.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.run.root; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/run/root/RootRunResult.java b/app/src/main/java/cn/classfun/droidvm/lib/run/root/RootRunResult.java index 03849259..e2e350ac 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/run/root/RootRunResult.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/run/root/RootRunResult.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.run.root; import com.topjohnwu.superuser.Shell; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/run/system/SystemRunContext.java b/app/src/main/java/cn/classfun/droidvm/lib/run/system/SystemRunContext.java index 72e5ed5c..0325cccf 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/run/system/SystemRunContext.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/run/system/SystemRunContext.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.run.system; import static java.util.Objects.requireNonNullElse; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/run/system/SystemRunResult.java b/app/src/main/java/cn/classfun/droidvm/lib/run/system/SystemRunResult.java index 0a8a3e64..103fe830 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/run/system/SystemRunResult.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/run/system/SystemRunResult.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.run.system; import static cn.classfun.droidvm.lib.utils.RunUtils.outStringToList; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/size/SizeNumber.java b/app/src/main/java/cn/classfun/droidvm/lib/size/SizeNumber.java index bc3ef75d..8cf230d5 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/size/SizeNumber.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/size/SizeNumber.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.size; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUnit.java b/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUnit.java index 4e482d88..50f627ad 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUnit.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUnit.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.size; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUtils.java index b5870fb2..19e33428 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/size/SizeUtils.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.size; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataConfig.java index 09ea8351..cf6d6395 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataConfig.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataConfig.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.base; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataItem.java b/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataItem.java index e1de9147..18bda4f2 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataItem.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataItem.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.base; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataStore.java b/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataStore.java index 87a1949a..4284dee3 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataStore.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/base/DataStore.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.base; import static cn.classfun.droidvm.lib.utils.FileUtils.loadJSONFile; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/base/JSONSerialize.java b/app/src/main/java/cn/classfun/droidvm/lib/store/base/JSONSerialize.java index 89625af0..fff22428 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/base/JSONSerialize.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/base/JSONSerialize.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.base; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/base/RingBuffer.java b/app/src/main/java/cn/classfun/droidvm/lib/store/base/RingBuffer.java index 95e54110..1b65fd1e 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/base/RingBuffer.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/base/RingBuffer.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.base; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskBus.java b/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskBus.java index f9bd3b99..0491b8c3 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskBus.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskBus.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.disk; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskConfig.java index cc8ac0fd..139d97f1 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskConfig.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskConfig.java @@ -1,8 +1,12 @@ +// 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.disk; import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.json.JSONException; import org.json.JSONObject; @@ -27,6 +31,27 @@ public String getFullPath() { return pathJoin(item.optString("folder", ""), getName()); } + /** + * The registered disk this one is an overlay of (mirrors the qcow2 header's backing file; + * the header stays the ground truth, this link is what the UI trees and lock rules read). + * Null for a standalone disk or when the parent isn't registered. + */ + @Nullable + public UUID getParentId() { + var s = item.optString("parent", ""); + if (s.isEmpty()) return null; + try { + return UUID.fromString(s); + } catch (IllegalArgumentException e) { + return null; + } + } + + public void setParentId(@Nullable UUID id) { + if (id == null) item.remove("parent"); + else item.set("parent", id.toString()); + } + @NonNull public DiskFormat getFormat() { return DiskFormat.fromFilename(getName()); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskStore.java b/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskStore.java index 1dce22b4..bb70d7e3 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskStore.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/disk/DiskStore.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.disk; import android.content.Context; @@ -9,6 +12,9 @@ import org.json.JSONObject; import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; import cn.classfun.droidvm.lib.store.base.DataStore; @@ -71,4 +77,33 @@ public DiskConfig findByPath(@NonNull String path) { } return null; } + + // Overlay-tree helpers. Linear scans: the registry holds tens of entries, so scanning IS the + // fast lookup, and unlike a cached index it can never go stale. Full-tree construction + // (cycle guard, depth cap, flattening) lives in DiskTree. + + /** Whether any registered disk is an overlay of {@code id} - the disk is then locked. */ + public boolean hasChildren(@NonNull UUID id) { + for (int i = 0; i < size(); i++) + if (id.equals(get(i).getParentId())) return true; + return false; + } + + /** All direct overlays of {@code id}, in registry order. */ + @NonNull + public List childrenOf(@NonNull UUID id) { + var out = new ArrayList(); + for (int i = 0; i < size(); i++) { + var cfg = get(i); + if (id.equals(cfg.getParentId())) out.add(cfg); + } + return out; + } + + /** The registered parent of {@code config}, or null (standalone / broken link). */ + @Nullable + public DiskConfig parentOf(@NonNull DiskConfig config) { + var parentId = config.getParentId(); + return parentId == null ? null : findById(parentId); + } } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/enums/ColorEnum.java b/app/src/main/java/cn/classfun/droidvm/lib/store/enums/ColorEnum.java index 4832cc1d..28d2abd1 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/enums/ColorEnum.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/enums/ColorEnum.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.enums; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/enums/EnumPicker.java b/app/src/main/java/cn/classfun/droidvm/lib/store/enums/EnumPicker.java index 11af0711..a9d65600 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/enums/EnumPicker.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/enums/EnumPicker.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.enums; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -13,14 +16,22 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Set; public final class EnumPicker> { private final Context context; private final Class enumClass; private final List items = new ArrayList<>(); + /** Items listed but refused; see {@link #setDisabledItems}. */ + private final Set disabled = new LinkedHashSet<>(); + private CharSequence disabledNote = null; private EnumPickerChanged onValueChanged = null; private int selectedIndex = -1; + /** Where a refused selection lands; see {@link #setDefaultItem}. */ + private int defaultIndex = 0; public interface EnumPickerChanged { @SuppressWarnings("unused") @@ -38,19 +49,15 @@ public AlertDialog showDialog(@Nullable CharSequence title) { if (items.isEmpty()) throw new IllegalStateException("Items cannot be empty"); var labels = new String[items.size()]; - for (int i = 0; i < items.size(); i++) { - var item = items.get(i); - var label = item.toString(); - if (item instanceof StringEnum) { - var se = (StringEnum) item; - label = se.getDisplayString(context); - } - labels[i] = label; - } + for (int i = 0; i < items.size(); i++) + labels[i] = menuLabel(items.get(i)); var b = new MaterialAlertDialogBuilder(context); if (title != null) b.setTitle(title); b.setSingleChoiceItems(labels, selectedIndex, (dialog, which) -> { + // A refused row leaves the dialog open rather than closing on a value it did not + // apply, which would read as having been accepted. + if (disabled.contains(items.get(which))) return; setSelectedIndex(which); dialog.dismiss(); }); @@ -64,12 +71,9 @@ public void showPopup(@NonNull View anchor) { var menu = popup.getMenu(); for (int i = 0; i < items.size(); i++) { var item = items.get(i); - var label = item.name(); - if (item instanceof StringEnum) { - var se = (StringEnum) item; - label = se.getDisplayString(context); - } - menu.add(0, i, i, label); + // MaterialMenu's adapter already honours isEnabled(): it greys the row and swallows + // the tap, so nothing here has to re-check on the way back out. + menu.add(0, i, i, menuLabel(item)).setEnabled(!disabled.contains(item)); } popup.setOnMenuItemClickListener(menuItem -> { setSelectedIndex(menuItem.getItemId()); @@ -78,8 +82,45 @@ public void showPopup(@NonNull View anchor) { popup.show(); } + /** This item's label, plus the disabled note when it is one of the refused ones. */ + @NonNull + private String menuLabel(@NonNull E item) { + var label = item instanceof StringEnum + ? ((StringEnum) item).getDisplayString(context) : item.name(); + if (disabledNote == null || !disabled.contains(item)) return label; + return fmt("%s (%s)", label, disabledNote); + } + + /** + * Items the picker lists but will not select. + * + *

    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.

    + */ + public void setDisabledItems(@Nullable CharSequence note, @NonNull Collection refused) { + disabled.clear(); + disabled.addAll(refused); + disabledNote = note; + if (selectedIndex >= 0 && selectedIndex < items.size() + && disabled.contains(items.get(selectedIndex))) + setSelectedIndex(fallbackIndex()); + } + public void autoItems() { items.clear(); + disabled.clear(); for (var item : getConstants()) { if (item instanceof StringEnum) { var se = (StringEnum) item; @@ -89,6 +130,7 @@ public void autoItems() { } if (items.isEmpty()) throw new IllegalStateException("No displayable constants found"); + defaultIndex = 0; selectedIndex = -1; setSelectedIndex(0); } @@ -106,6 +148,8 @@ public void setItems(@NonNull List constants) { throw new IllegalArgumentException("Constants cannot be empty"); items.clear(); items.addAll(constants); + disabled.clear(); + defaultIndex = 0; selectedIndex = -1; setSelectedIndex(0); } @@ -131,6 +175,7 @@ public void setOnValueChangedListener(@Nullable Runnable listener) { setOnValueChangedListener(listener == null ? null : (o, n) -> listener.run()); } + @SuppressWarnings("unused") public int getSelectedIndex() { return selectedIndex; } @@ -153,11 +198,78 @@ public E getSelectedItem() { return items.get(selectedIndex); } - public void setSelectedItem(@NonNull E item) { + /** + * Selects {@code item}, or the default when this picker will not take it. + * + *

    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.

    + * + * @return whether {@code item} itself was selected + */ + public boolean setSelectedItem(@NonNull E item) { int index = items.indexOf(item); - if (index < 0) - throw new IllegalArgumentException("Item not found in items"); + if (index < 0 || disabled.contains(item)) { + setSelectedIndex(fallbackIndex()); + return false; + } setSelectedIndex(index); + return true; + } + + /** + * The item a refused selection falls back to. Defaults to the first one, which is also what a + * freshly installed set selects. + * + *

    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.

    + */ + public void setDefaultItem(@NonNull E item) { + int index = items.indexOf(item); + if (index < 0) + throw new IllegalArgumentException("Default item not found in items"); + defaultIndex = index; + } + + /** The default's index, or the first selectable item when the default is itself refused. */ + private int fallbackIndex() { + if (defaultIndex >= 0 && defaultIndex < items.size() + && !disabled.contains(items.get(defaultIndex))) + return defaultIndex; + for (int i = 0; i < items.size(); i++) + if (!disabled.contains(items.get(i))) return i; + // Every item refused. Nothing here is selectable, so the head is as good an answer as any + // -- and better than leaving the picker with no selection for getSelectedItem() to fail on. + return 0; + } + + /** + * Steps to the next item, refused ones skipped: the rotate-mode button's whole gesture. + * + *

    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.

    + */ + public void selectNext() { + for (int step = 1; step <= items.size(); step++) { + var index = (selectedIndex + step) % items.size(); + if (disabled.contains(items.get(index))) continue; + setSelectedIndex(index); + return; + } } @NonNull @@ -170,6 +282,7 @@ public String getSelectedString() { return item.name(); } + @SuppressWarnings("unused") public int getItemCount() { return items.size(); } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/enums/Enums.java b/app/src/main/java/cn/classfun/droidvm/lib/store/enums/Enums.java index 5918898f..251a0969 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/enums/Enums.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/enums/Enums.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.enums; import android.widget.TextView; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/enums/StringEnum.java b/app/src/main/java/cn/classfun/droidvm/lib/store/enums/StringEnum.java index dea25264..72d41928 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/enums/StringEnum.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/enums/StringEnum.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.enums; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/network/BridgeType.java b/app/src/main/java/cn/classfun/droidvm/lib/store/network/BridgeType.java index bd8f10da..1fa5e26b 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/network/BridgeType.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/network/BridgeType.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.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/network/Ipv6Source.java b/app/src/main/java/cn/classfun/droidvm/lib/store/network/Ipv6Source.java index e27728bb..fae94108 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/network/Ipv6Source.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/network/Ipv6Source.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.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConfig.java index d44e27af..9ce83272 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConfig.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConfig.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.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConfigValidator.java b/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConfigValidator.java index 8ba24cfc..e02fc9d3 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConfigValidator.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConfigValidator.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.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -18,6 +21,13 @@ * with a human-readable message on the first violation found. */ public final class NetworkConfigValidator { + /** + * Cap on a bridge interface name. 12, so the longest derived name still fits IFNAMSIZ (15 + * usable): a per-VLAN bridge / trunk leg appends "v" or "." plus a 2-char VLAN code (bridge + * + 3). See LinuxNetwork.vlanCode / perVlanBridge. + */ + public static final int MAX_BRIDGE_NAME_LEN = 12; + private NetworkConfigValidator() { } @@ -28,11 +38,9 @@ public static void validate(@NonNull NetworkConfig config) { var bridgeName = config.getBridgeName(); if (bridgeName == null || !bridgeName.matches("[a-zA-Z][a-zA-Z0-9_-]*")) throw new IllegalArgumentException(fmt("Invalid bridge name: %s", bridgeName)); - // Cap at 12 so the longest derived name still fits IFNAMSIZ (15 usable): - // a per-VLAN bridge / trunk leg appends "v" or "." plus a 2-char VLAN - // code (bridge + 3). See LinuxNetwork.vlanCode / perVlanBridge. - if (bridgeName.length() > 12) - throw new IllegalArgumentException("Bridge name longer than 12 characters"); + if (bridgeName.length() > MAX_BRIDGE_NAME_LEN) + throw new IllegalArgumentException(fmt( + "Bridge name longer than %d characters", MAX_BRIDGE_NAME_LEN)); var type = config.getBridgeType(); var mode = config.getUplinkMode(); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConflicts.java b/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConflicts.java new file mode 100644 index 00000000..437e81aa --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkConflicts.java @@ -0,0 +1,202 @@ +// 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.network; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import cn.classfun.droidvm.lib.network.IPv4Network; +import cn.classfun.droidvm.lib.network.IPv6Network; +import cn.classfun.droidvm.lib.store.base.DataStore; + +/** + * What two networks may not share, and -- the point of this class -- which two networks that + * question is even asked about. + * + *

    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 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 others, + @Nullable UUID exclude + ) { + var mine4 = new ArrayList(); + var mine6 = new ArrayList(); + collectSubnets(cfg.getVlans(), mine4, mine6); + var myUplink = cfg.getUplinkMode() == UplinkMode.L2 ? cfg.getL2Uplink() : null; + for (var other : others) { + if (exclude != null && exclude.toString().equals(other.item.optString("id", ""))) + continue; + if (!sameKind(cfg, other)) continue; + if (myUplink != null) { + var theirs = other.getL2Uplink(); + if (theirs != null && theirs.trim().equalsIgnoreCase(myUplink.trim())) + return new Conflict(Kind.UPLINK, myUplink, theirs, other); + continue; + } + var conflict = findAddressConflict(mine4, mine6, other); + if (conflict != null) return conflict; + } + return null; + } + + @Nullable + private static Conflict findAddressConflict( + @NonNull List mine4, + @NonNull List mine6, + @NonNull NetworkConfig other + ) { + var their4 = new ArrayList(); + var their6 = new ArrayList(); + collectSubnets(other.getVlans(), their4, their6); + for (var mine : mine4) + for (var theirs : their4) + if (mine.overlaps(theirs)) return new Conflict( + Kind.IPV4, mine.toString(), theirs.toString(), other); + for (var mine : mine6) + for (var theirs : their6) + if (mine.overlaps(theirs)) return new Conflict( + Kind.IPV6, mine.toString(), theirs.toString(), other); + return null; + } + + /** + * The first pair of this config's own subnets that overlap each other, as {@code {a, b}}, or + * null when it is self-consistent. Not scoped by anything: one network's VLANs share a stack + * by definition. + */ + @Nullable + public static String[] findSelfOverlap(@NonNull NetworkConfig cfg) { + var mine4 = new ArrayList(); + var mine6 = new ArrayList(); + collectSubnets(cfg.getVlans(), mine4, mine6); + for (int i = 0; i < mine4.size(); i++) + for (int j = i + 1; j < mine4.size(); j++) + if (mine4.get(i).overlaps(mine4.get(j))) return new String[]{ + mine4.get(i).toString(), mine4.get(j).toString()}; + for (int i = 0; i < mine6.size(); i++) + for (int j = i + 1; j < mine6.size(); j++) + if (mine6.get(i).overlaps(mine6.get(j))) return new String[]{ + mine6.get(i).toString(), mine6.get(j).toString()}; + return null; + } + + /** Appends every subnet these VLANs hold, primary and secondary, to the given lists. */ + public static void collectSubnets( + @NonNull Iterable vlans, + @NonNull List out4, + @NonNull List out6 + ) { + for (var vlan : vlans) { + var net4 = vlan.getIpv4Network(); + if (net4 != null) out4.add(net4); + for (var cidr : vlan.getIpv4Secondary()) { + try { + out4.add(IPv4Network.parse(cidr)); + } catch (Exception ignored) { + } + } + var net6 = vlan.getIpv6Network(); + if (net6 != null) out6.add(net6); + for (var cidr : vlan.getIpv6Secondary()) { + try { + out6.add(IPv6Network.parse(cidr)); + } catch (Exception ignored) { + } + } + } + } + + /** Every config in a store, as a plain list. */ + @NonNull + public static List snapshot(@NonNull DataStore store) { + var out = new ArrayList(); + for (int i = 0; i < store.size(); i++) out.add(store.get(i)); + return out; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkState.java b/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkState.java index e46628c1..ff565547 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkState.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkState.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.network; import androidx.annotation.ColorRes; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkStore.java b/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkStore.java index b2b7f7d8..6e160edb 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkStore.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/network/NetworkStore.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.network; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/network/UplinkMode.java b/app/src/main/java/cn/classfun/droidvm/lib/store/network/UplinkMode.java index b5a6d013..cd0468e3 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/network/UplinkMode.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/network/UplinkMode.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.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/network/VlanConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/network/VlanConfig.java index 8bf15f15..ca416143 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/network/VlanConfig.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/network/VlanConfig.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.network; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/BootConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/BootConfig.java index 97a0f551..d0f79e19 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/BootConfig.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/BootConfig.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 androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/CpuPlacementDraft.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/CpuPlacementDraft.java new file mode 100644 index 00000000..15ef0ab8 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/CpuPlacementDraft.java @@ -0,0 +1,50 @@ +// 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 java.util.List; +import java.util.Map; + +/** + * A vCPU placement as the editor holds it, before it becomes a + * {@link CpuPlacementPlan}: the affinity map, the CPU count it is keyed + * against, and the guest topology fields that describe it. + * + *

    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> affinity; + /** The VM's CPU count. Simple mode derives it from the bound host cores. */ + public final int vcpuCount; + /** Derive guest capacity/cluster from the affinity instead of the fields below. */ + public final boolean auto; + /** Hand-written {@code --cpu-capacity}, only meaningful when {@link #auto} is off. */ + @NonNull + public final String manualCapacity; + /** Hand-written {@code --cpu-cluster}, only meaningful when {@link #auto} is off. */ + @NonNull + public final String manualClusters; + + public CpuPlacementDraft( + @NonNull Map> affinity, + int vcpuCount, + boolean auto, + @NonNull String manualCapacity, + @NonNull String manualClusters + ) { + this.affinity = CpuPlacementPlan.orderedCopy(affinity); + this.vcpuCount = Math.max(1, vcpuCount); + this.auto = auto; + this.manualCapacity = manualCapacity; + this.manualClusters = manualClusters; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/CpuPlacementPlan.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/CpuPlacementPlan.java new file mode 100644 index 00000000..1fe87831 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/CpuPlacementPlan.java @@ -0,0 +1,411 @@ +// 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.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NavigableMap; +import java.util.TreeMap; +import java.util.TreeSet; + +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.utils.CpuUtils; + +/** + * Where a VM's vCPUs run on the host, and what the guest is told about it. + * + *

    The three crosvm flags this resolves are one decision seen from three + * sides, not independent knobs: + *

      + *
    • {@code --cpu-affinity} pins each vCPU thread to host cores. + *
    • {@code --cpu-capacity} writes {@code capacity-dmips-mhz} into the guest + * device tree, so the guest scheduler knows how strong each vCPU is. + *
    • {@code --cpu-cluster} writes the guest {@code cpu-map}, so the guest + * sees the same big/little split as the host. + *
    + * 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. + * + *

    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> affinity; + /** vCPU index to guest-visible capacity; only for vCPUs that have one. */ + @NonNull + public final Map capacity; + /** Guest cluster membership, each entry a set of vCPU indices. */ + @NonNull + public final List> clusters; + + private CpuPlacementPlan( + @NonNull Map> affinity, + @NonNull Map capacity, + @NonNull List> 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 args) { + if (affinity.isEmpty()) return; + args.add("--cpu-affinity"); + args.add(formatAffinity(affinity)); + if (!capacity.isEmpty()) { + args.add("--cpu-capacity"); + args.add(formatCapacity(capacity)); + } + // One flag per cluster; a lone cluster is what crosvm does by default + // anyway, so it is not worth an FDT cpu-map. + if (clusters.size() > 1) { + for (var cluster : clusters) { + if (cluster.isEmpty()) continue; + args.add("--cpu-cluster"); + args.add(CpuUtils.compactRanges(joinCsv(cluster))); + } + } + } + + /** + * Whether this VM's GPU worker cpuset should be built and named at all. + * + *

    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.

    + */ + public static boolean wantsGpuCgroup(@NonNull DataItem item) { + return item.optBoolean(KEY_GPU_CGROUP, false) && VMScreenConfig.hasGpuDevice(item); + } + + // --- affinity --- + + /** + * Parse the per-vCPU affinity form. Assignments are {@code ':'}-separated + * and each maps one vCPU to a CPUSET; malformed assignments and empty host + * sets are dropped. The plain global-CPUSET form crosvm also accepts is not + * represented here -- the editor always writes per-vCPU assignments -- so a + * hand-written global mask parses to empty and is simply not carried over. + */ + @NonNull + public static Map> parseAffinity(@NonNull String spec) { + var out = new TreeMap>(); + if (spec.trim().isEmpty()) return out; + for (var assignment : spec.split(":")) { + assignment = assignment.trim(); + if (assignment.isEmpty()) continue; + int eq = assignment.indexOf('='); + if (eq <= 0) continue; + int vcpu; + try { + vcpu = Integer.parseInt(assignment.substring(0, eq).trim()); + } catch (NumberFormatException e) { + continue; + } + if (vcpu < 0) continue; + var hosts = CpuUtils.parseCpuSet(assignment.substring(eq + 1)); + if (hosts.isEmpty()) continue; + out.put(vcpu, hosts); + } + return out; + } + + @NonNull + public static String formatAffinity(@NonNull Map> affinity) { + var sb = new StringBuilder(); + for (var entry : new TreeMap<>(affinity).entrySet()) { + if (entry.getValue().isEmpty()) continue; + if (sb.length() > 0) sb.append(':'); + sb.append(entry.getKey()).append('=') + .append(CpuUtils.compactRanges(joinCsv(entry.getValue()))); + } + return sb.toString(); + } + + /** + * True when {@code affinity} is exactly what the editor's simple mode can + * express: every vCPU below {@code vcpuCount} bound to one host core of its + * own, no core shared, and nothing bound past the count. A vCPU floating + * over several cores, an unbound vCPU or two vCPUs on one core all need the + * per-vCPU editor to be described, and answer false here. + */ + public static boolean isOneToOne( + @NonNull Map> affinity, int vcpuCount + ) { + if (vcpuCount <= 0 || affinity.size() != vcpuCount) return false; + var hosts = new TreeSet(); + for (int vcpu = 0; vcpu < vcpuCount; vcpu++) { + var bound = affinity.get(vcpu); + if (bound == null || bound.size() != 1) return false; + if (!hosts.add(bound.get(0))) return false; + } + return true; + } + + /** + * The 1:1 map over {@code hostCores}: lowest core index becomes vCPU 0, the + * next vCPU 1, and so on. Inverse of {@link #oneToOneHosts}. + */ + @NonNull + public static Map> oneToOne(@NonNull Collection hostCores) { + var out = new TreeMap>(); + int vcpu = 0; + for (var host : new TreeSet<>(hostCores)) + out.put(vcpu++, new ArrayList<>(List.of(host))); + return out; + } + + /** + * The host cores a 1:1 map pins, ascending; empty when the map is not 1:1 + * over {@code vcpuCount} vCPUs. + */ + @NonNull + public static List oneToOneHosts( + @NonNull Map> affinity, int vcpuCount + ) { + if (!isOneToOne(affinity, vcpuCount)) return new ArrayList<>(); + var hosts = new TreeSet(); + for (var bound : affinity.values()) hosts.add(bound.get(0)); + return new ArrayList<>(hosts); + } + + /** + * The 1:1 selection closest to an arbitrary map, for the advanced-to-simple + * switch: each vCPU in turn keeps the lowest core it is bound to that an + * earlier vCPU has not already claimed; its remaining cores, and a vCPU left + * with nothing to claim, are dropped. Ascending, so the result can be handed + * straight to {@link #oneToOne} -- which is why the vCPU a core ends up on + * need not be the one it came from. + */ + @NonNull + public static List flattenToOneToOne( + @NonNull Map> affinity + ) { + var taken = new TreeSet(); + for (var entry : new TreeMap<>(affinity).entrySet()) { + for (var host : entry.getValue()) + if (taken.add(host)) break; + } + return new ArrayList<>(taken); + } + + // --- capacity --- + + /** Parse {@code 0=792,6=1024}; unparsable pairs are dropped. */ + @NonNull + public static Map parseCapacity(@NonNull String spec) { + var out = new TreeMap(); + for (var pair : spec.split(",")) { + pair = pair.trim(); + if (pair.isEmpty()) continue; + int eq = pair.indexOf('='); + if (eq <= 0) continue; + try { + var vcpu = Integer.parseInt(pair.substring(0, eq).trim()); + var cap = Long.parseLong(pair.substring(eq + 1).trim()); + if (vcpu >= 0 && cap > 0) out.put(vcpu, cap); + } catch (NumberFormatException ignored) { + } + } + return out; + } + + @NonNull + public static String formatCapacity(@NonNull Map capacity) { + var sb = new StringBuilder(); + for (var entry : new TreeMap<>(capacity).entrySet()) { + if (sb.length() > 0) sb.append(','); + sb.append(fmt("%d=%d", entry.getKey(), entry.getValue())); + } + return sb.toString(); + } + + /** + * Capacity of each pinned vCPU: the weakest host core it can land on. Taking + * the minimum rather than the maximum keeps the guest scheduler honest -- a + * vCPU floating across a little and a big core can end up on the little one, + * and promising the big core's capacity would make the guest over-commit it. + */ + @NonNull + public static Map deriveCapacity( + @NonNull Map> affinity, + @NonNull List cores + ) { + var byIndex = new TreeMap(); + for (var core : cores) byIndex.put(core.index, core.capacity); + var out = new TreeMap(); + for (var entry : affinity.entrySet()) { + long min = 0; + for (var host : entry.getValue()) { + var cap = byIndex.get(host); + if (cap == null || cap <= 0) continue; + min = min == 0 ? cap : Math.min(min, cap); + } + if (min > 0) out.put(entry.getKey(), min); + } + return out; + } + + // --- clusters --- + + /** Parse {@code 0-5;6} into one vCPU list per cluster. */ + @NonNull + public static 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> groupByCapacity( + @NonNull Map capacity) { + var byCapacity = new TreeMap>(); + for (var entry : new TreeMap<>(capacity).entrySet()) { + byCapacity.computeIfAbsent(entry.getValue(), k -> new ArrayList<>()) + .add(entry.getKey()); + } + return byCapacity; + } + + /** + * Group vCPUs of equal capacity into one cluster each, weakest first, which + * reproduces the host's big/little split on the guest side. + * + *

    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> deriveClusters( + @NonNull Map capacity, int vcpuCount) { + var byCapacity = groupByCapacity(capacity); + if (byCapacity.isEmpty()) return new ArrayList<>(); + var weakest = byCapacity.firstEntry().getValue(); + for (int vcpu = 0; vcpu < vcpuCount; vcpu++) { + if (!capacity.containsKey(vcpu)) weakest.add(vcpu); + } + weakest.sort(Integer::compareTo); + return new ArrayList<>(byCapacity.values()); + } + + // --- shared helpers --- + + /** vCPU indices that appear in more than one cluster (crosvm rejects those). */ + @NonNull + public static List findClusterOverlaps(@NonNull List> clusters) { + var seen = new TreeSet(); + var dupes = new TreeSet(); + for (var cluster : clusters) + for (var vcpu : cluster) + if (!seen.add(vcpu)) dupes.add(vcpu); + return new ArrayList<>(dupes); + } + + /** Host cores shared between a vCPU affinity map and a CPUSET spec. */ + @NonNull + public static List findHostOverlaps( + @NonNull Map> affinity, + @NonNull String cpuSet + ) { + var other = new TreeSet<>(CpuUtils.parseCpuSet(cpuSet)); + var shared = new TreeSet(); + for (var hosts : affinity.values()) + for (var host : hosts) + if (other.contains(host)) shared.add(host); + return new ArrayList<>(shared); + } + + /** Ordered copy keyed by vCPU, so callers can edit without losing order. */ + @NonNull + public static Map> orderedCopy( + @NonNull Map> affinity + ) { + var out = new LinkedHashMap>(); + for (var entry : new TreeMap<>(affinity).entrySet()) + out.put(entry.getKey(), new ArrayList<>(entry.getValue())); + return out; + } + + @NonNull + private static String joinCsv(@NonNull List values) { + var sb = new StringBuilder(); + for (var value : values) { + if (sb.length() > 0) sb.append(','); + sb.append(value); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/DisplayBackend.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/DisplayBackend.java index c8d91240..4735a92e 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/DisplayBackend.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/DisplayBackend.java @@ -1,43 +1,18 @@ +// 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.StringRes; - -import cn.classfun.droidvm.R; -import cn.classfun.droidvm.lib.store.enums.StringEnum; - -public enum DisplayBackend implements StringEnum { - NONE(0, "none", R.string.nullptr), - SIMPLEFB(1, "simple", R.string.create_vm_display_backend_simplefb), - VIRTIO_GPU(2, "virtio-gpu", R.string.create_vm_display_backend_virtio_gpu); - - private final int value; - private final String name; - private final @StringRes int stringId; - - DisplayBackend(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; - } +/** + * The legacy either/or display producer: one display, and this said which device made it. + * + *

    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.

    + */ +public enum DisplayBackend { + NONE, + SIMPLEFB, + VIRTIO_GPU, } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/DisplayExporter.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/DisplayExporter.java new file mode 100644 index 00000000..28280252 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/DisplayExporter.java @@ -0,0 +1,69 @@ +// 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.StringRes; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.enums.StringEnum; + +/** + * Who consumes one screen's frames -- the export side of a {@link VMScreenConfig} binding. + *

    + * 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.

    + */ +public enum DisplayTransportCap implements StringEnum { + /** Always reachable: host memcpy into the sink's own buffer. The bottom of every ladder. */ + CPU(0, "cpu", R.string.create_vm_screen_transport_cpu), + /** A Vulkan blit on the host, which gets a format conversion thrown in for free. */ + GPU(1, "gpu", R.string.create_vm_screen_transport_gpu), + /** Native display only: the sink lends its render target and the content never crosses. */ + ZERO(2, "zero", R.string.create_vm_screen_transport_zero), + /** VNC only: the blit's result goes to a hardware video encoder rather than into RFB. */ + GPU_HW(3, "gpu-hw", R.string.create_vm_screen_transport_gpu_hw); + + /** + * The width granularity a screen whose stride is exactly {@code width * 4} needs before the + * GPU copy can take it, in pixels. + * + *

    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.

    + */ + public static final long GPU_COPY_WIDTH_ALIGN = 16; + + private final int value; + private final String token; + private final @StringRes int stringId; + + DisplayTransportCap(int value, String token, @StringRes int stringId) { + this.value = value; + this.token = token; + this.stringId = stringId; + } + + @SuppressWarnings("unused") + public int getValue() { + return value; + } + + /** + * The stored spelling, which is also the token crosvm's {@code transport-cap=} takes. Lower + * case and hyphenated, unlike the other persisted enums, because this one is written onto a + * command line as well as into the config and one value must not have two spellings. That is + * also why it is parsed by {@link #fromToken} rather than by the generic enum helper, whose + * upper-casing cannot round-trip a hyphen. + */ + @NonNull + public String getToken() { + return token; + } + + @Override + @StringRes + public int getStringId() { + return stringId; + } + + @Override + public boolean isDisplay() { + return stringId != R.string.nullptr; + } + + /** The stored token back to a constant, or null for absent, empty or unrecognised. */ + @Nullable + public static DisplayTransportCap fromToken(@Nullable String stored) { + if (stored == null || stored.isEmpty()) return null; + for (var cap : values()) + if (cap.token.equalsIgnoreCase(stored)) return cap; + return null; + } + + /** + * The ladder this (screen, exporter) edge has, bottom rung first. + * + *

    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.

    + */ + @NonNull + public static DisplayTransportCap[] optionsFor(@NonNull String screenId, + @NonNull DisplayExporter exporter) { + switch (exporter) { + case NATIVE: + return VMScreenConfig.ID_GPU0.equals(screenId) + ? new DisplayTransportCap[]{CPU, GPU, ZERO} + : new DisplayTransportCap[]{CPU, GPU}; + case VNC: + return new DisplayTransportCap[]{CPU, GPU, GPU_HW}; + default: + return new DisplayTransportCap[0]; + } + } + + /** Whether [cap] is one of the rungs this edge has at all. */ + public static boolean isOfferedFor(@NonNull String screenId, + @NonNull DisplayExporter exporter, + @NonNull DisplayTransportCap cap) { + for (var option : optionsFor(screenId, exporter)) + if (option == cap) return true; + return false; + } + + /** + * Whether this build can actually reach [cap] on this edge. + * + *

    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.

    + */ + public static boolean isImplemented(@NonNull DisplayExporter exporter, + @NonNull DisplayTransportCap cap) { + switch (exporter) { + case NATIVE: + return cap == CPU || cap == GPU; + case VNC: + return cap == CPU || cap == GPU || cap == GPU_HW; + default: + return false; + } + } + + /** The rungs this edge shows but cannot honour yet, in {@link #optionsFor} order. */ + @NonNull + public static DisplayTransportCap[] unimplementedFor(@NonNull String screenId, + @NonNull DisplayExporter exporter) { + var options = optionsFor(screenId, exporter); + var n = 0; + for (var option : options) + if (!isImplemented(exporter, option)) n++; + var out = new DisplayTransportCap[n]; + var i = 0; + for (var option : options) + if (!isImplemented(exporter, option)) out[i++] = option; + return out; + } + + /** + * Whether a GPU-copy ceiling on this edge will settle on the CPU copy anyway, because the + * screen is [width] pixels wide and the blit cannot import a frame that shape. + * + *

    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.

    + */ + public static boolean cpuFallbackFromWidth(@NonNull String screenId, + @NonNull DisplayExporter exporter, + @NonNull DisplayTransportCap ceiling, + long width) { + if (!VMScreenConfig.ID_SIMPLEFB.equals(screenId)) return false; + if (ceiling != GPU && ceiling != GPU_HW) return false; + if (!isImplemented(exporter, ceiling)) return false; + return width % GPU_COPY_WIDTH_ALIGN != 0; + } + + /** + * The ceiling a screen gets when it has not named one: the highest rung this build can + * actually reach on that edge. + * + *

    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.

    + */ + @NonNull + public static DisplayTransportCap defaultFor(@NonNull String screenId, + @NonNull DisplayExporter exporter) { + var best = CPU; + for (var option : optionsFor(screenId, exporter)) + if (isImplemented(exporter, option)) best = option; + return best; + } + + /** + * The token {@code transport-cap=} should carry for this binding, or null to send no flag. + * + *

    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.

    + */ + @Nullable + public static String emittedToken(@NonNull String screenId, + @NonNull DisplayExporter exporter, + @NonNull DisplayTransportCap ceiling) { + var options = optionsFor(screenId, exporter); + var top = defaultFor(screenId, exporter); + var ceilingAt = -1; + var topAt = -1; + for (var i = 0; i < options.length; i++) { + if (options[i] == ceiling) ceilingAt = i; + if (options[i] == top) topAt = i; + } + if (ceilingAt < 0 || ceilingAt >= topAt) return null; + return ceiling.token; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuApi.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuApi.java index fb449411..a5f7ff79 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuApi.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuApi.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 androidx.annotation.StringRes; @@ -10,7 +13,17 @@ public enum GpuApi implements StringEnum { VULKAN(1, "vulkan", R.string.create_vm_gpu_api_vulkan), EGL(2, "egl", R.string.create_vm_gpu_api_egl), OPENGLES(3, "gles", R.string.create_vm_gpu_api_opengles), - ANGLE(4, "angle", R.string.create_vm_gpu_api_angle); + ANGLE(4, "angle", R.string.create_vm_gpu_api_angle), + // gfxstream host Vulkan driver (ANDROID_EMU_VK_LOADER_PATH). SYSTEM = the SoC's stock + // Vulkan HAL; TURNIP = bundled Mesa turnip (Adreno); PANVK = Mesa PanVK (Mali) -- not yet wired. + VULKAN_SYSTEM(5, "vulkan-system", R.string.create_vm_gpu_api_vulkan_system), + VULKAN_TURNIP(6, "vulkan-turnip", R.string.create_vm_gpu_api_vulkan_turnip), + VULKAN_PANVK(7, "vulkan-panvk", R.string.create_vm_gpu_api_vulkan_panvk), + // virglrenderer DRM native context: the guest runs its own turnip over vdrm and + // virglrenderer translates the msm protocol to KGSL ioctls. Not a translation API like the + // others -- nothing is remoted at the GL/VK level -- but it is the same choice for the user: + // how the guest reaches the GPU. + DRM2KGSL(8, "drm2kgsl", R.string.create_vm_gpu_api_drm2kgsl); private final int value; private final String name; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuBackend.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuBackend.java index c8628da8..d17b0cc9 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuBackend.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuBackend.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 androidx.annotation.StringRes; @@ -5,11 +8,19 @@ import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.store.enums.StringEnum; +/** + * Which renderer serves the guest's graphics, listed in the order the editor offers them: software + * 2D first, then the two proxying renderers. + * + *

    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.

    + */ public enum GpuBackend implements StringEnum { NONE(0, "none", R.string.nullptr), GPU_2D(1, "2d", R.string.create_vm_gpu_backend_2d), - GPU_VIRGLRENDERER(2, "virglrenderer", R.string.create_vm_gpu_backend_virglrenderer), - GPU_GFXSTREAM(3, "gfxstream", R.string.create_vm_gpu_backend_gfxstream); + GPU_GFXSTREAM(3, "gfxstream", R.string.create_vm_gpu_backend_gfxstream), + GPU_VIRGLRENDERER(2, "virglrenderer", R.string.create_vm_gpu_backend_virglrenderer); private final int value; private final String name; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuBlitProvider.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuBlitProvider.java new file mode 100644 index 00000000..a6e88f28 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/GpuBlitProvider.java @@ -0,0 +1,78 @@ +// 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 Vulkan driver performs the native display's GPU blit -- import the virtio-gpu scanout + * dmabuf as a VkImage and blit it into the SurfaceControl buffer (the "1-gpu-copy" path in + * devices/src/virtio/gpu). This is a separate axis from the render {@link GpuProvider}: the driver + * that executes the guest's proxied calls and the driver that composites the scanout need not be + * the same one. Only the accelerated scanout uses it at all -- VNC and simplefb present through + * crosvm's CPU copy and ignore this entirely. + * + *

    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. + * + *

      + *
    • {@link #OPENGL} -- GL calls are proxied. Guest runs mesa's virgl gallium driver; + * virglrenderer replays the command stream on a host GL context. Capset virgl2. + *
    • {@link #VULKAN} -- Vulkan calls are proxied. On gfxstream that is its Vulkan + * component (capset gfxstream-vulkan); on virglrenderer it is venus (capset venus). Either + * way the host side needs an ICD, which is what {@link GpuProvider}'s VK_* row picks. + *
    • {@link #NATIVE} -- only kernel ioctls are proxied. The guest runs its REAL driver + * (turnip over vdrm here) and the host translates the DRM uAPI. Capset drm; which DRM + * backend answers is a property of the device, KGSL on Adreno. + *
    + * + *

    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__input_multitouch.sock} came to 106 -- one byte of headroom -- so + * the moment the screen id joined it the per-screen names hit 115 and 111 and crosvm refused the + * whole command line with "path must be shorter than SUN_LEN". Nothing outside this process pair + * ever reads these names: the daemon binds the inode and crosvm is handed the path on the command + * line it is started with, so unlike the service name and the evdev names, which the guest and the + * user's saved mappings key on across reboots, a socket filename can be abbreviated freely. + * {@link #inputSocketPath} therefore builds {@code dvmin_[_]_.sock} out of + * two-to-three-letter tags, worst case 90 bytes, and {@link #requireBindablePath} makes the + * remaining margin a wall rather than a hope -- see its note on what bind(2) does otherwise. */ public final class NativeDisplay { - /** Input channels (MVP: multi-touch + keyboard). */ + /** + * Input channels. Each maps to one crosvm {@code --input } virtio-input device and one + * unix socket, and the UI routes to whichever the current {@code InputMode} selects + * (multi-touch, relative mouse, or absolute single-touch tablet). Ordinals are the wire + * channel ids shared with the daemon's InputHandler; append new channels, never renumber. + * + *

    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.

    + */ public static final int MULTITOUCH = 0; public static final int KEYBOARD = 1; - public static final int CHANNEL_COUNT = 2; + public static final int MOUSE = 2; + public static final int TABLET = 3; + public static final int CHANNEL_COUNT = 4; /** * Broadcast the daemon (running as root) sends to hand its INativeDisplayRootService binder to @@ -35,28 +79,241 @@ public final class NativeDisplay { /** Per-attach random token; the UI only accepts a broadcast carrying the nonce it requested. */ public static final String EXTRA_NONCE = "nonce"; - private static final String[] KINDS = {"multitouch", "keyboard"}; + // Socket filename tags per channel (index == channel constant). "ms"/"tab" are our tags; the + // crosvm device kinds they pair with are "mouse" (relative) and "absolute-mouse". Short + // because the whole path has to fit sun_path (see the class note); still self-describing, + // because the next person reading `ls run/` deserves to know which inode is the touchscreen. + private static final String[] CHANNEL_TAGS = {"mt", "kbd", "ms", "tab"}; + + /** + * Socket filename tag per screen, positionally paired with {@link VMScreenConfig#IDS}. A + * screen id is a word ("simplefb"); a socket name has a couple of bytes to spend on it. The + * static check below is the wall a third screen walks into: adding an id without adding its + * tag fails at class load rather than minting a name that overflows or collides. + */ + private static final String[] SCREEN_TAGS = {"g0", "sfb"}; + + static { + if (SCREEN_TAGS.length != VMScreenConfig.IDS.length) + throw new IllegalStateException("every screen id needs an input-socket tag"); + } + private static final String RUN_PATH = pathJoin(DATA_DIR, "run"); + /** Prefix every name built here starts with; {@link #vmIdFromServiceName} reads it back. */ + private static final String NAME_PREFIX = "droidvm_disp_"; + + /** Prefix of the input socket filenames; short, and not a name anything looks up by. */ + private static final String SOCKET_PREFIX = "dvmin_"; + + /** + * Bytes of {@code sockaddr_un.sun_path} on Linux -- 108, of which the last must be the NUL, + * so 107 is the longest bindable path. Not a tunable: it is the kernel's array size. + */ + public static final int SUN_PATH_SIZE = 108; + + /** The longest path {@link #requireBindablePath} will pass: {@link #SUN_PATH_SIZE} less NUL. */ + public static final int MAX_UNIX_PATH = SUN_PATH_SIZE - 1; + private NativeDisplay() { } - /** Per-VM crosvm display service name; also used as the vmKey for input sockets. */ + /** + * The VM's display-channel root: the stem every per-screen service name is built on, and the + * name a pre-screens build registered on its own. Stable across boots because the VM's UUID + * is. Taken as a raw id rather than a config because half its callers only have the id, out + * of an Intent extra or a service name being read back. + */ + @NonNull + public static String channelKeyFromId(@NonNull String vmId) { + return fmt("%s%s", NAME_PREFIX, sanitize(vmId)); + } + + /** + * The servicemanager name crosvm registers for one screen's native display -- the value of + * that screen's {@code --android-display-service name=}, and what the UI looks up. + * + * The screen id rides in the name rather than in a separate field because the name is the + * whole identity of a channel on both sides of the binder, and because it has to survive a + * crosvm restart unchanged: it is derived, never allocated. The charset stays the one + * {@link #sanitize} allows, so the name is legal both as a binder service name and inside a + * socket filename. + */ + @NonNull + public static String serviceName(@NonNull VMConfig config, @NonNull String screenId) { + return serviceNameFromId(config.getId().toString(), screenId); + } + + /** Same as {@link #serviceName(VMConfig, String)} but from a raw VM id. */ + @NonNull + public static String serviceNameFromId(@NonNull String vmId, @NonNull String screenId) { + return fmt("%s_%s", channelKeyFromId(vmId), sanitize(screenId)); + } + + /** + * The VM id a name built here belongs to, or an empty string when the name is not ours. + * + * The daemon uses this to decide whether a display binder is worth waiting for, so it has to + * be the exact inverse of the two builders above -- which is why it lives next to them + * instead of being a prefix-strip written out again at the call site. + */ + @NonNull + public static String vmIdFromServiceName(@NonNull String serviceName) { + if (!serviceName.startsWith(NAME_PREFIX)) return ""; + var rest = serviceName.substring(NAME_PREFIX.length()); + for (var screenId : VMScreenConfig.IDS) { + var suffix = fmt("_%s", sanitize(screenId)); + if (rest.endsWith(suffix)) + return rest.substring(0, rest.length() - suffix.length()); + } + return rest; + } + + /** + * Whether [channel]'s device belongs to one screen rather than to the whole VM. + * + *

    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.

    + */ + public static boolean isPerScreen(int channel) { + return channel == MULTITOUCH || channel == TABLET || channel == KEYBOARD; + } + + /** + * The socket filename tag for [screenId], or the sanitized id itself if the screen is one + * {@link #SCREEN_TAGS} has never heard of -- a long name the length check will catch, which is + * the failure worth having over a short one that might collide with another screen's tag. + */ @NonNull - public static String serviceName(@NonNull VMConfig config) { - return serviceNameFromId(config.getId().toString()); + private static String screenTag(@NonNull String screenId) { + for (int i = 0; i < VMScreenConfig.IDS.length; i++) + if (VMScreenConfig.IDS[i].equals(screenId)) return SCREEN_TAGS[i]; + return sanitize(screenId); } - /** Same as {@link #serviceName(VMConfig)} but from a raw VM id (e.g. an Intent extra). */ + /** + * The socket path for [channel] on [screenId] of [vmId]: {@code dvmin___.sock}, + * with the screen tag left out entirely for the VM-wide channel -- so passing the empty + * screen id for the relative pointer is exact rather than a placeholder, and no screen a + * console could name reaches its inode. + * + *

    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.

    + */ @NonNull - public static String serviceNameFromId(@NonNull String vmId) { - return fmt("droidvm_disp_%s", sanitize(vmId)); + public static String inputSocketPath(@NonNull String vmId, @NonNull String screenId, + int channel) { + var screen = isPerScreen(channel) ? fmt("_%s", screenTag(screenId)) : ""; + return pathJoin(RUN_PATH, fmt("%s%s%s_%s.sock", + SOCKET_PREFIX, sanitize(vmId), screen, CHANNEL_TAGS[channel])); } - /** The socket path crosvm connects to for [vmKey]'s [channel]. Must match across all callers. */ + /** + * Returns [path] if a unix socket can actually be bound to it, and throws naming the path and + * its length if not. + * + *

    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.

    + */ + @NonNull + public static String requireBindablePath(@NonNull String path) { + int len = path.getBytes(StandardCharsets.UTF_8).length; + if (len > MAX_UNIX_PATH) + throw new IllegalArgumentException(fmt( + "unix socket path is %d bytes, over the %d sun_path allows: %s", + len, MAX_UNIX_PATH, path)); + return path; + } + + /** + * The evdev name crosvm gives [screenId]'s multi-touch device -- what the guest sees as the + * touchscreen's name, and the whole of its identity there. + * + *

    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.

    + */ + @NonNull + public static String touchDeviceName(@NonNull String screenId) { + return fmt("DroidVM Touch (%s)", screenId); + } + + /** + * The evdev name crosvm gives [screenId]'s absolute-pointer (tablet) device. + * + *

    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}.

    + */ + @NonNull + public static String tabletDeviceName(@NonNull String screenId) { + return fmt("DroidVM Tablet (%s)", screenId); + } + + /** + * The evdev name [screenId]'s keyboard carries. + * + *

    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.

    + */ @NonNull - public static String inputSocketPath(@NonNull String vmKey, int channel) { - return pathJoin(RUN_PATH, fmt("%s_input_%s.sock", sanitize(vmKey), KINDS[channel])); + public static String keyboardDeviceName(@NonNull String screenId) { + return fmt("DroidVM Keyboard (%s)", screenId); } /** Keep socket/service names to a filesystem- and binder-safe charset. */ diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/NicLeaseOffsets.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/NicLeaseOffsets.java new file mode 100644 index 00000000..7f7c3f09 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/NicLeaseOffsets.java @@ -0,0 +1,142 @@ +// 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 java.math.BigInteger; +import java.util.Set; + +import cn.classfun.droidvm.lib.store.network.NetworkConfig; +import cn.classfun.droidvm.lib.store.network.VlanConfig; + +/** + * Which DHCP static-lease offsets a VLAN has left, and which one a NIC should take. + * + *

    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 used, + @NonNull VMConfig vm, + @NonNull NetworkConfig network, + @NonNull VlanConfig vlan, + @NonNull Family family + ) { + addOffsets(used, vm, network, vlan, family, null); + } + + /** + * The same, with one NIC left out -- the one being resolved, whose own offset is the thing + * being asked about and so must not count as taken. + */ + public static void addOffsets( + @NonNull Set used, + @NonNull VMConfig vm, + @NonNull NetworkConfig network, + @NonNull VlanConfig vlan, + @NonNull Family family, + @Nullable VMNicConfig exclude + ) { + var netIdStr = network.item.optString("id", ""); + if (netIdStr.isEmpty()) return; + vm.forEachNic(nic -> { + // same underlying entry, re-wrapped by forEachNic + if (exclude != null && nic.item == exclude.item) return; + if (!netIdStr.equals(nic.getNetworkId())) return; + if (!hasOffset(nic, family)) return; + var nicVlan = nic.resolveDhcpVlan(network); + if (nicVlan == null || nicVlan.getVlanId() != vlan.getVlanId()) return; + used.add(offsetOf(nic, family)); + }); + } + + /** + * The offset a NIC should end up with: {@code wanted} when nothing is in its way, otherwise + * the next free one above it, wrapping back to {@link #FIRST} when the top of the VLAN is + * reached. Returns -1 when the VLAN has no free offset at all (or cannot host one), which is + * the caller's cue to fall back to a dynamic address. + * + *

    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 used, + @NonNull VlanConfig vlan, + @NonNull Family family + ) { + long max = maxOffset(vlan, family); + if (max < FIRST) return -1; + long poolStart = family == Family.IPV4 + ? vlan.getDhcp4OffsetStart() : vlan.getDhcp6OffsetStart(); + long poolEnd = family == Family.IPV4 + ? vlan.getDhcp4OffsetEnd() : vlan.getDhcp6OffsetEnd(); + long span = max - FIRST + 1; + long probes = Math.min(span, MAX_PROBES); + long start = wanted < FIRST || wanted > max ? FIRST : wanted; + for (long i = 0; i < probes; i++) { + // wrap rather than stop at the top: the fallback is a dynamic + // address, so a free offset below the wanted one still beats it + long c = FIRST + ((start - FIRST + i) % span); + if (c >= poolStart && c <= poolEnd) continue; // the dynamic pool + if (used.contains(c)) continue; + return c; + } + return -1; + } + + /** The highest offset this VLAN can address, or -1 when it has no network of that family. */ + private static long maxOffset(@NonNull VlanConfig vlan, @NonNull Family family) { + if (family == Family.IPV4) { + var net4 = vlan.getIpv4Network(); + // addressAtOffset is valid for 1..total-2 + return net4 == null ? -1 : net4.totalAddresses() - 2; + } + var net6 = vlan.getIpv6Network(); + // A delegated prefix has no CIDR here until it is handed one at run time; its host part + // is a /64's worth either way, so the probe cap is the only bound that matters. + if (net6 == null) return vlan.hasIpv6() ? Long.MAX_VALUE - 1 : -1; + var total = net6.totalAddresses().subtract(BigInteger.valueOf(2)); + var cap = BigInteger.valueOf(Long.MAX_VALUE - 1); + return total.compareTo(cap) >= 0 ? Long.MAX_VALUE - 1 : total.longValue(); + } + + private static boolean hasOffset(@NonNull VMNicConfig nic, @NonNull Family family) { + if (family == Family.IPV4) return nic.isDhcp4LeaseEnabled() && nic.hasDhcp4Offset(); + return nic.isDhcp6LeaseEnabled() && nic.hasDhcp6Offset(); + } + + private static long offsetOf(@NonNull VMNicConfig nic, @NonNull Family family) { + return family == Family.IPV4 ? nic.getDhcp4Offset() : nic.getDhcp6Offset(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/PeripheralType.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/PeripheralType.java new file mode 100644 index 00000000..ddf61c71 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/PeripheralType.java @@ -0,0 +1,112 @@ +// 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 android.content.pm.ServiceInfo; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.enums.StringEnum; + +/** + * Kind of virtual peripheral attached to a VM -- one entry here is one device the guest sees. + * + *

    The list deliberately names hardware rather than roles. An earlier version offered + * "Speaker" and "Microphone", which reads well but does not survive contact with the devices: + * a virtio-snd card is one direction with one host endpoint, while an Intel HDA codec is a + * single card carrying both. Anything that maps roles onto devices has to guess, and the guess + * is wrong for one of the two. Naming the device and putting the role inside it keeps the UI + * and the command line the same shape.

    + */ +public enum PeripheralType implements StringEnum { + /** virtio-snd, one PCM direction per device. Served by an unprivileged vhost-user helper. */ + VIRTIO_SOUND(R.string.edit_vm_peripheral_type_virtio_sound, R.drawable.ic_speaker, true, ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE), + /** + * Intel HD Audio codec: one card, playback and capture together. Present so the model is + * honest about what a guest could have -- Windows has an in-box driver for it, which + * virtio-snd does not -- but crosvm emulates no HDA controller, so nothing can serve it yet. + */ + INTEL_HDA(R.string.edit_vm_peripheral_type_intel_hda, R.drawable.ic_microphone, false, ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE), + /** + * virtio-media capture device: one host camera, seen by the guest as one {@code /dev/videoX}. + * + *

    One entry is one camera, unlike the sound card above. That is the driver's shape rather + * than a UI choice: a virtio-media device registers exactly one {@code video_device} whose + * capabilities come from a single config word, so a second camera is a second device. A VM + * that wants front and back carries two of these.

    + * + *

    Unavailable until crosvm carries the device; the host half (Camera2 NDK through + * {@code android_camera}) exists, the virtio-media capture device on top of it does not.

    + */ + VIRTIO_CAMERA(R.string.edit_vm_peripheral_type_virtio_camera, R.drawable.ic_camera, false, ServiceInfo.FOREGROUND_SERVICE_TYPE_CAMERA); + + private final @StringRes int titleId; + private final @DrawableRes int iconId; + private final boolean available; + private final int foregroundServiceType; + + PeripheralType(@StringRes int titleId, @DrawableRes int iconId, boolean available, + int foregroundServiceType) { + this.titleId = titleId; + this.iconId = iconId; + this.available = available; + this.foregroundServiceType = foregroundServiceType; + } + + @Override + public int getStringId() { + return titleId; + } + + @DrawableRes + public int getIconId() { + return iconId; + } + + /** False when nothing on the host can serve this device yet; the UI says so and the + * backends skip it rather than starting a VM that lies about its hardware. */ + public boolean isAvailable() { + return available; + } + + /** + * Whether a running VM carrying this device needs the app to hold a foreground service. + * + *

    Some host APIs are only open to a uid the platform considers foreground, and the state + * is a property of the uid, not of the process that calls: crosvm is forked by the + * root daemon and ActivityManager does not know it exists, so nothing it does can put its uid + * in that state. Only a process ActivityManager manages -- the app's own -- can, and a + * foreground service is how it does so without a visible activity.

    + * + *

    Camera is the first such device: {@code CAMERA} is a foreground-only runtime permission, + * so AppOps resolves it to MODE_IGNORED unless the uid carries + * {@code PROCESS_CAPABILITY_FOREGROUND_CAMERA}, which comes from a foreground service typed + * {@code camera}. Microphone works the same way, through + * {@code PROCESS_CAPABILITY_FOREGROUND_MICROPHONE}, and will want a type here once anyone + * checks whether guest capture survives the app going background.

    + * + *

    A type rather than a yes/no, because a foreground service has to declare which kind it + * is and the two above need different ones -- a boolean would leave the service guessing. + * Naming the type here is still the whole switch: nothing else tests for a device kind, and + * the service unions the types of whatever is running.

    + */ + public int getForegroundServiceType() { + return foregroundServiceType; + } + + /** Convenience for the common question; the type is the source of truth. */ + public boolean needsForegroundService() { + return foregroundServiceType != ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE; + } + + /** The service types {@code types} need together, or 0 when none do. */ + public static int foregroundServiceTypesOf(@NonNull Iterable types) { + int mask = ServiceInfo.FOREGROUND_SERVICE_TYPE_NONE; + for (var type : types) mask |= type.getForegroundServiceType(); + return mask; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/PortProtocol.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/PortProtocol.java index edca416f..0f39edfd 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/PortProtocol.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/PortProtocol.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 androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/ProtectedVM.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/ProtectedVM.java index 49f2866b..a06e4d05 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/ProtectedVM.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/ProtectedVM.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 androidx.annotation.StringRes; @@ -8,7 +11,18 @@ public enum ProtectedVM implements StringEnum { PROTECTED_NORMAL(0, R.string.create_vm_protected_normal), PROTECTED_PROTECTED(1, R.string.create_vm_protected_protected), - PROTECTED_WITHOUT_FIRMWARE(2, R.string.create_vm_protected_without_firmware); + PROTECTED_WITHOUT_FIRMWARE(2, R.string.create_vm_protected_without_firmware), + /** + * Protected as far as the hypervisor is concerned, but the guest's RAM is SHARE'd to it at + * run time instead of lent before boot, so the host can still reach it. Gunyah only. + * + *

    The difference the user sees is which guests boot: a protected VM needs a kernel built + * with {@code CONFIG_RESTRICTED_DMA_POOL}, because its memory is lent and every virtio buffer + * has to travel through a bounce pool. No distribution builds that. Here there is nothing to + * bounce through, so a stock distribution kernel boots -- which is why this mode is not one + * of the two the boot tab warns about. + */ + PSEUDO_UNPROTECTED(3, R.string.create_vm_protected_pseudo_unprotected); private final int value; private final @StringRes int stringId; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SerialBackend.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SerialBackend.java new file mode 100644 index 00000000..641e598c --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SerialBackend.java @@ -0,0 +1,71 @@ +// 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.StringRes; + +import cn.classfun.droidvm.R; + +import cn.classfun.droidvm.lib.store.enums.StringEnum; + +/** + * Where a serial port's bytes go on the host -- one per "serial_ports" entry. + * + *

    Most map 1:1 onto a crosvm {@code --serial type=}. The exception is {@code APP_CONSOLE}: + * crosvm's {@code file} type pointed at a pipe pair the daemon keeps, which is what shows up + * as a text console in the app.

    + */ +public enum SerialBackend implements StringEnum { + /** Bytes are discarded; input is never delivered. crosvm {@code type=sink}. */ + SINK(R.string.edit_vm_serial_backend_sink, false, true), + /** Wired to a daemon pipe pair and shown as an interactive text console in the app. */ + APP_CONSOLE(R.string.edit_vm_serial_backend_app_console, false, true), + /** Output appended to a file on the host; no input. crosvm {@code type=file}. */ + FILE(R.string.edit_vm_serial_backend_file, true, true), + /** Output datagrams to an existing unix socket; no input. crosvm {@code type=unix}. */ + UNIX(R.string.edit_vm_serial_backend_unix, true, true), + /** Bidirectional unix stream socket (crosvm connects). crosvm {@code type=unix-stream}. */ + UNIX_STREAM(R.string.edit_vm_serial_backend_unix_stream, true, true), + /** + * crosvm-opened pty ({@code type=pty}); the path field, when set, becomes a symlink to the + * slave so consumers find it at a stable name. + */ + PTY(R.string.edit_vm_serial_backend_pty, true, true), + /** crosvm's stdout (ends up in the daemon log). crosvm {@code type=stdout}. */ + STDOUT(R.string.edit_vm_serial_backend_stdout, false, true), + /** Host syslog. crosvm {@code type=syslog}. */ + SYSLOG(R.string.edit_vm_serial_backend_syslog, false, true), + /** + * USB gadget CDC-ACM port towards an external host: the daemon grafts an acm function + * onto the gadget and crosvm opens the resulting ttyGSn ({@code type=dev}). Binding + * re-enumerates USB for a moment, and the port lives on whichever USB connection is + * currently active. + */ + USB_ACM(R.string.edit_vm_serial_backend_usb_acm, false, true); + + private final @StringRes int titleId; + private final boolean usesPath; + private final boolean available; + + SerialBackend(@StringRes int titleId, boolean usesPath, boolean available) { + this.titleId = titleId; + this.usesPath = usesPath; + this.available = available; + } + + @Override + public int getStringId() { + return titleId; + } + + /** True when the row shows the path field (mandatory except for {@link #PTY}). */ + public boolean usesPath() { + return usesPath; + } + + /** False when nothing on the host can serve this backend yet; the daemon degrades to sink. */ + public boolean isAvailable() { + return available; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SerialHardware.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SerialHardware.java new file mode 100644 index 00000000..c9d7e157 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SerialHardware.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.lib.store.vm; + +import androidx.annotation.DrawableRes; +import androidx.annotation.StringRes; + +import cn.classfun.droidvm.R; + +import cn.classfun.droidvm.lib.store.enums.StringEnum; + +/** + * Kind of serial port hardware the guest sees -- one entry of a VM's "serial_ports" array names + * one of these. + * + *

    The four PC-style 16550 COM ports are special: crosvm creates all four unconditionally + * (unconfigured ones are sinks), so they exist as fixed rows that can only change backend, and + * {@link #isAddable()} is false. The other kinds are standalone devices the user adds.

    + */ +public enum SerialHardware implements StringEnum { + /** PC-style 8250/16550 COM port. Always four of them (num 1-4); fixed, not addable. */ + SERIAL(R.string.edit_vm_serial_hw_serial, R.drawable.ic_serial_port, false, 4), + /** + * ARM SBSA UART (PL011 subset). The one serial device Windows-on-ARM has an in-box driver + * for (SerPL011.sys); crosvm wires a single instance. + */ + SBSA(R.string.edit_vm_serial_hw_sbsa, R.drawable.ic_serial_port, true, 1), + /** virtio-console port; needs a virtio driver in the guest (hvcN on Linux). */ + VIRTIO_CONSOLE(R.string.edit_vm_serial_hw_virtio_console, R.drawable.ic_serial_port, true, 4); + + private final @StringRes int titleId; + private final @DrawableRes int iconId; + private final boolean addable; + private final int maxPorts; + + SerialHardware(@StringRes int titleId, @DrawableRes int iconId, boolean addable, int maxPorts) { + this.titleId = titleId; + this.iconId = iconId; + this.addable = addable; + this.maxPorts = maxPorts; + } + + @Override + public int getStringId() { + return titleId; + } + + @DrawableRes + public int getIconId() { + return iconId; + } + + /** False for the fixed 16550 quartet, which exists whether configured or not. */ + public boolean isAddable() { + return addable; + } + + /** Highest port number (1-based) the backend wires for this hardware. */ + public int getMaxPorts() { + return maxPorts; + } + + /** The value crosvm's {@code --serial hardware=} option expects. */ + public String getCrosvmName() { + switch (this) { + case SBSA: return "sbsa"; + case VIRTIO_CONSOLE: return "virtio-console"; + case SERIAL: + default: return "serial"; + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SharedDirCache.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SharedDirCache.java index 03baec59..da921faf 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SharedDirCache.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SharedDirCache.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 androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SharedDirType.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SharedDirType.java index 9f34edfd..ec6bf289 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SharedDirType.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SharedDirType.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 androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundBuffer.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundBuffer.java new file mode 100644 index 00000000..ceb1fbdb --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundBuffer.java @@ -0,0 +1,50 @@ +// 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 cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.enums.StringEnum; + +/** + * How much audio the guest driver should keep queued ahead of the device. + * + *

    This is the latency knob, and it is a real trade rather than a tuning detail. Every period + * the guest has not queued in time is a hole the device has to fill, and a hole is a click. On a + * VM with no display driver -- where compositing and video decode run on the CPU -- scheduling + * gaps of tens of milliseconds are ordinary, so a shallow queue clicks on exactly the content + * that loads the guest hardest.

    + * + *

    The value reaches the driver through the device's vendor config block; a driver that does + * not read it keeps its own default. It is counted in periods rather than milliseconds because + * a period's duration is not known until the format is negotiated -- 2048 bytes is about 10.7ms + * at 48kHz stereo 16-bit, and something else at any other rate, so a figure in milliseconds + * could only ever be approximate while the count is exact.

    + * + *

    The deepest setting matches the driver's IO pool, which is what bounds how many periods it + * can have in flight at once. Asking for more than the pool holds is quietly clamped, so the two + * numbers are kept equal deliberately.

    + */ +public enum SoundBuffer implements StringEnum { + LOW(2, R.string.edit_vm_sound_buffer_low), + NORMAL(6, R.string.edit_vm_sound_buffer_normal), + SAFE(12, R.string.edit_vm_sound_buffer_safe); + + private final int packets; + private final int titleId; + + SoundBuffer(int packets, int titleId) { + this.packets = packets; + this.titleId = titleId; + } + + @Override + public int getStringId() { + return titleId; + } + + /** Periods the guest should try to keep in flight. */ + public int getPackets() { + return packets; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundMode.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundMode.java new file mode 100644 index 00000000..47ecfed8 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundMode.java @@ -0,0 +1,29 @@ +// 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 cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.enums.StringEnum; + +/** Which direction a virtio-snd device carries. One device is one direction. */ +public enum SoundMode implements StringEnum { + SPEAKER(R.string.edit_vm_sound_mode_speaker), + MICROPHONE(R.string.edit_vm_sound_mode_microphone); + + private final int titleId; + + SoundMode(int titleId) { + this.titleId = titleId; + } + + @Override + public int getStringId() { + return titleId; + } + + /** True when the host device feeds the guest rather than the other way round. */ + public boolean isInput() { + return this == MICROPHONE; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundPurpose.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundPurpose.java new file mode 100644 index 00000000..3a864d97 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundPurpose.java @@ -0,0 +1,126 @@ +// 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 java.util.ArrayList; +import java.util.List; + +import cn.classfun.droidvm.R; + +/** + * What a stream is for, as distinct from which endpoint it is on. + * + *

    Android decides two things from this that the endpoint alone does not settle: which + * microphone it picks when none was named, and what processing it applies to one that was -- + * echo cancellation and noise suppression are properties of the purpose, not of the microphone. + * On the output side it decides which volume stream the audio belongs to and how it ducks + * against other audio.

    + * + *

    These are AAudio's own values, named as AAudio names them. The two system presets + * (SYSTEM_HOTWORD, SYSTEM_ECHO_REFERENCE) are deliberately absent: they exist, but need + * privileges an ordinary application does not have, so offering them would only produce a + * stream that fails to open.

    + */ +public final class SoundPurpose { + private SoundPurpose() { + } + + /** One selectable value of one attribute. */ + public static final class Choice { + /** As it appears in the stored key; matches AAudio's own name, lowercased. */ + public final String value; + @StringRes + public final int titleId; + + Choice(@NonNull String value, @StringRes int titleId) { + this.value = value; + this.titleId = titleId; + } + } + + /** Attribute name in the stored key: {@code TYPE|address#usage=media,content=music}. */ + public static final String ATTR_USAGE = "usage"; + public static final String ATTR_CONTENT = "content"; + public static final String ATTR_PRESET = "preset"; + + private static final Choice[] USAGE = { + new Choice("media", R.string.edit_vm_sound_usage_media), + new Choice("voice_communication", R.string.edit_vm_sound_usage_voice_communication), + new Choice("voice_communication_signalling", + R.string.edit_vm_sound_usage_voice_communication_signalling), + new Choice("game", R.string.edit_vm_sound_usage_game), + new Choice("alarm", R.string.edit_vm_sound_usage_alarm), + new Choice("notification", R.string.edit_vm_sound_usage_notification), + new Choice("notification_ringtone", R.string.edit_vm_sound_usage_notification_ringtone), + new Choice("notification_event", R.string.edit_vm_sound_usage_notification_event), + new Choice("assistant", R.string.edit_vm_sound_usage_assistant), + new Choice("assistance_accessibility", + R.string.edit_vm_sound_usage_assistance_accessibility), + new Choice("assistance_navigation_guidance", + R.string.edit_vm_sound_usage_assistance_navigation_guidance), + new Choice("assistance_sonification", + R.string.edit_vm_sound_usage_assistance_sonification), + }; + + private static final Choice[] CONTENT = { + new Choice("speech", R.string.edit_vm_sound_content_speech), + new Choice("music", R.string.edit_vm_sound_content_music), + new Choice("movie", R.string.edit_vm_sound_content_movie), + new Choice("sonification", R.string.edit_vm_sound_content_sonification), + }; + + private static final Choice[] PRESET = { + new Choice("generic", R.string.edit_vm_sound_preset_generic), + new Choice("camcorder", R.string.edit_vm_sound_preset_camcorder), + new Choice("voice_recognition", R.string.edit_vm_sound_preset_voice_recognition), + new Choice("voice_communication", R.string.edit_vm_sound_preset_voice_communication), + new Choice("unprocessed", R.string.edit_vm_sound_preset_unprocessed), + new Choice("voice_performance", R.string.edit_vm_sound_preset_voice_performance), + }; + + /** The attributes offered for one direction, in the order they are asked about. */ + @NonNull + public static List attributesFor(boolean input) { + var out = new ArrayList(); + if (input) { + out.add(ATTR_PRESET); + } else { + out.add(ATTR_USAGE); + out.add(ATTR_CONTENT); + } + return out; + } + + @NonNull + public static Choice[] choicesFor(@NonNull String attribute) { + switch (attribute) { + case ATTR_USAGE: return USAGE; + case ATTR_CONTENT: return CONTENT; + case ATTR_PRESET: return PRESET; + default: return new Choice[0]; + } + } + + @StringRes + public static int titleFor(@NonNull String attribute) { + switch (attribute) { + case ATTR_USAGE: return R.string.edit_vm_sound_purpose_usage; + case ATTR_CONTENT: return R.string.edit_vm_sound_purpose_content; + default: return R.string.edit_vm_sound_purpose_preset; + } + } + + /** The label for a stored value, or null when it is not one this build offers. */ + @Nullable + public static Choice find(@NonNull String attribute, @NonNull String value) { + for (var choice : choicesFor(attribute)) { + if (choice.value.equals(value)) return choice; + } + return null; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundUnderrun.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundUnderrun.java new file mode 100644 index 00000000..f9ec6144 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/SoundUnderrun.java @@ -0,0 +1,57 @@ +// 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 cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.enums.StringEnum; + +/** + * What the host device plays when the guest has not queued a period in time. + * + *

    Silence is honest and maximally audible; continuing the waveform hides short holes, which + * is the trade a shallow queue wants to make. Pairs with {@link SoundBuffer}: the lower the + * latency, the more often there is something to conceal.

    + */ +public enum SoundUnderrun implements StringEnum { + /** A period of zeroes. Honest, and the most audible thing there is. */ + SILENCE(R.string.edit_vm_sound_underrun_silence, true), + /** + * Repeats the last pitch period. crosvm finds the period by autocorrelation over the tail of + * the previous audio, repeats it in phase, fades it out across a few periods, and crossfades + * real audio back in. Only 16-bit PCM is concealed; anything else falls back to silence. + */ + REPEAT(R.string.edit_vm_sound_underrun_repeat, true), + /** + * Waveform Similarity Overlap-Add: searches for the best-matching window at each splice and + * overlap-adds, rather than repeating one period unchanged. Costs more and does not lock to + * a single pitch, so a long hole does not turn into a held note. + */ + WSOLA(R.string.edit_vm_sound_underrun_wsola, true), + /** + * Linear-predictive extrapolation: fits an all-pole filter to the previous audio and excites + * it to continue the signal, rather than reusing samples. What VoIP codecs do: the formants + * come from the filter, so what does get repeated is the excitation, which loops far less + * audibly than a waveform does. + */ + LPC(R.string.edit_vm_sound_underrun_lpc, true); + + private final int titleId; + private final boolean implemented; + + SoundUnderrun(int titleId, boolean implemented) { + this.titleId = titleId; + this.implemented = implemented; + } + + @Override + public int getStringId() { + return titleId; + } + + /** Hides the unimplemented modes from the picker; see {@code EnumPicker}. */ + @Override + public boolean isDisplay() { + return implemented; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMBackend.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMBackend.java index f40c452d..96fd0e77 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMBackend.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMBackend.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 androidx.annotation.StringRes; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMConfig.java index 4ef28212..bf23f799 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMConfig.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMConfig.java @@ -1,6 +1,13 @@ +// 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.PATH_EDK2_FIRMWARE; +import static cn.classfun.droidvm.lib.Constants.PATH_BUILTIN_INITRD; +import static cn.classfun.droidvm.lib.Constants.PATH_BUILTIN_KERNEL; + +import android.content.Context; import androidx.annotation.NonNull; @@ -15,6 +22,14 @@ import cn.classfun.droidvm.lib.store.base.DataItem; public class VMConfig extends DataConfig { + public static final boolean NEW_VM_DEFAULT_HUGEPAGES = true; + public static final boolean NEW_VM_DEFAULT_PMU = true; + public static final boolean NEW_VM_DEFAULT_RNG = true; + public static final boolean NEW_VM_DEFAULT_SMT = true; + public static final boolean NEW_VM_DEFAULT_USB = true; + public static final ProtectedVM NEW_VM_DEFAULT_PROTECTED_VM = + ProtectedVM.PSEUDO_UNPROTECTED; + public VMConfig() { setId(UUID.randomUUID()); item.set("created_at", System.currentTimeMillis()); @@ -28,6 +43,114 @@ public VMConfig(@NonNull JSONObject obj) throws JSONException { } migrateBoot(); migrateNicForwards(); + // Configs from before "screens" describe one display chosen by an either/or backend + // enum; fold that into the per-screen bindings, dropping the legacy keys so nothing + // downstream can read a second, disagreeing answer. + VMScreenConfig.migrate(item); + // Configs from before "serial_ports" implicitly meant "COM1 = app console, rest sinks"; + // make that explicit so every reader sees the same list. + VMSerialConfig.ensureDefaults(item); + } + + /** + * Materializes the values shown by every tab when Customize opens for a new VM. Quick + * creation starts here and only replaces the fields it exposes, so both creation paths keep + * the same defaults as those defaults evolve. + */ + @NonNull + public static VMConfig createWithCustomizeDefaults(@NonNull Context context) { + var config = new VMConfig(); + var item = config.item; + item.set("memory_mb", 512L); + item.set("cpu_count", 1L); + item.set("swiotlb_mb", 256L); + item.set("balloon", false); + item.set("pmu", NEW_VM_DEFAULT_PMU); + item.set("rng", NEW_VM_DEFAULT_RNG); + item.set("smt", NEW_VM_DEFAULT_SMT); + item.set("usb", NEW_VM_DEFAULT_USB); + item.set("sandbox", false); + item.set("hugepages", NEW_VM_DEFAULT_HUGEPAGES); + item.set("strace", false); + item.set("gpu_vram_folio_threshold_kb", 1024L); + item.set(LendMthpMode.KEY, LendMthpMode.defaultForDevice(context)); + item.set("protected_vm", NEW_VM_DEFAULT_PROTECTED_VM); + item.set("backend", VMBackend.DEFAULT); + item.set("hypervisor", VMHypervisor.defaultForNewVm(VMBackend.DEFAULT)); + item.set("extra_options", DataItem.newArray()); + item.set("environment_variables", DataItem.newArray()); + item.set(CpuPlacementPlan.KEY_AFFINITY, ""); + item.set(CpuPlacementPlan.KEY_AUTO, true); + item.set(CpuPlacementPlan.KEY_CAPACITY, ""); + item.set(CpuPlacementPlan.KEY_CLUSTERS, ""); + + var boot = BootConfig.of(config); + boot.setProtocol(BootConfig.Protocol.UEFI); + boot.setUefiFirmware(""); + boot.setUefiVarsEnabled(true); + boot.setUefiVars(""); + boot.setLinuxSource(BootConfig.LinuxSource.MANUAL); + boot.setKernel(PATH_BUILTIN_KERNEL); + boot.setInitrd(PATH_BUILTIN_INITRD); + boot.setCmdline(BootConfig.DEFAULT_MANUAL_CMDLINE); + boot.setImageCmdline(""); + boot.setImageDisk(0); + boot.setVdafix(true); + boot.setBootWait(BootConfig.DEFAULT_BOOT_WAIT); + item.set("auto_up", false); + + item.set("disks", DataItem.newArray()); + item.set("shared_dirs", DataItem.newArray()); + item.set("networks", DataItem.newArray()); + + var gpu = VMScreenConfig.of(item, VMScreenConfig.ID_GPU0); + gpu.setEnabled(false); + // Set even though the screen is off, because this is what the editor shows the moment the + // user turns virtio-gpu on -- the row's own default stopped being reachable when a new VM + // started going through loadConfig like an existing one. Nothing is exported until the + // screen is enabled: save() writes NONE for a screen that is off, and the backend emits + // exporters only for enabled screens. + gpu.setExporter(VMScreenConfig.NEW_VM_DEFAULT_EXPORTER); + gpu.setTransportCap(DisplayTransportCap.defaultFor( + VMScreenConfig.ID_GPU0, VMScreenConfig.NEW_VM_DEFAULT_EXPORTER)); + gpu.setInputEnabled(true); + // Written even though the screen is off and its exporter is not VNC, for the same reason + // the exporter above is: the editor loads this config over its rows, so what it shows the + // moment the user switches this screen to VNC is what is written here. The two screens get + // different ports because they can be exported at once and two servers may not share one. + gpu.setVncHost(VMScreenConfig.NEW_VM_DEFAULT_VNC_HOST); + gpu.setVncPort(VMScreenConfig.newVmDefaultVncPort(VMScreenConfig.ID_GPU0)); + gpu.setWidth(VMScreenConfig.DEFAULT_WIDTH); + gpu.setHeight(VMScreenConfig.DEFAULT_HEIGHT); + gpu.setRefreshRate(VMScreenConfig.DEFAULT_REFRESH_RATE); + gpu.setDpiH(VMScreenConfig.DEFAULT_DPI); + gpu.setDpiV(VMScreenConfig.DEFAULT_DPI); + + var simpleFb = VMScreenConfig.of(item, VMScreenConfig.ID_SIMPLEFB); + simpleFb.setEnabled(true); + simpleFb.setExporter(VMScreenConfig.NEW_VM_DEFAULT_EXPORTER); + simpleFb.setTransportCap(DisplayTransportCap.defaultFor( + VMScreenConfig.ID_SIMPLEFB, VMScreenConfig.NEW_VM_DEFAULT_EXPORTER)); + simpleFb.setInputEnabled(true); + simpleFb.setVncHost(VMScreenConfig.NEW_VM_DEFAULT_VNC_HOST); + simpleFb.setVncPort(VMScreenConfig.newVmDefaultVncPort(VMScreenConfig.ID_SIMPLEFB)); + simpleFb.setWidth(VMScreenConfig.DEFAULT_WIDTH); + simpleFb.setHeight(VMScreenConfig.DEFAULT_HEIGHT); + simpleFb.setPollHz(VMScreenConfig.NEW_VM_DEFAULT_POLL_HZ); + item.set("display_blit_provider", GpuBlitProvider.TURNIP); + item.set(CpuPlacementPlan.KEY_GPU_CGROUP, false); + item.set(CpuPlacementPlan.KEY_GPU_CGROUP_PATH, + CpuPlacementPlan.DEFAULT_GPU_CGROUP_PATH); + item.set(CpuPlacementPlan.KEY_GPU_CGROUP_CPUS, ""); + VpuConfig.setEnabled(item, false); + VpuConfig.setHostPoolMb(item, VpuConfig.DEFAULT_HOST_POOL_MB); + VpuConfig.setGuestPoolMb(item, VpuConfig.DEFAULT_GUEST_POOL_MB); + + var peripherals = DataItem.newArray(); + peripherals.append(VMPeripheralConfig.createDefaultVirtioSound().item); + item.set("peripherals", peripherals); + VMSerialConfig.ensureDefaults(item); + return config; } /** @@ -122,6 +245,23 @@ private void migrateNicForwards() { item.remove("port_forwards"); } + /** + * Free-form Markdown the user keeps with this VM -- what it is for, how to log in, what not + * to touch. Empty when unset, never null, so every reader can ask {@code isEmpty()}. It rides + * along in a package like any other field, which is the point: the notes are about the VM, + * not about the phone it happens to be on. + */ + @NonNull + public final String getNotes() { + var notes = item.optString("notes", ""); + return notes == null ? "" : notes; + } + + public final void setNotes(@NonNull String notes) { + if (notes.isEmpty()) item.remove("notes"); + else item.set("notes", notes); + } + /** Iterates this VM's NIC entries (the "networks" array). */ public final void forEachNic(@NonNull Consumer consumer) { var nets = item.opt("networks", null); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMHypervisor.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMHypervisor.java index e592d500..88acb951 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMHypervisor.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMHypervisor.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.utils.FileUtils.shellCheckExists; @@ -34,6 +37,12 @@ public int getStringId() { return stringId; } + /** AUTO remains readable for old configs, but is no longer offered for new edits. */ + @Override + public boolean isDisplay() { + return this != AUTO; + } + @Nullable public String getDevicePath() { return devicePath; @@ -82,4 +91,25 @@ public static VMHypervisor findPreferredHypervisor( public static VMHypervisor findPreferredHypervisor(@Nullable VMBackend backend) { return findPreferredHypervisor(backend, List.of(values())); } + + /** Resolves the legacy AUTO value at the one shared backend/device decision point. */ + @Nullable + public static VMHypervisor resolveConfigured( + @Nullable VMBackend backend, @Nullable VMHypervisor configured + ) { + return configured == null || configured == AUTO + ? findPreferredHypervisor(backend) : configured; + } + + /** + * Concrete value written for a new VM. The fallback preserves the old failure mode on a + * device with no usable hardware node (crosvm has no software accelerator), while avoiding + * an AUTO value that can silently change meaning after the config is created. + */ + @NonNull + public static VMHypervisor defaultForNewVm(@NonNull VMBackend backend) { + var resolved = findPreferredHypervisor(backend); + if (resolved != null) return resolved; + return backend == VMBackend.QEMU ? SOFT : KVM; + } } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMNicConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMNicConfig.java index d3be02f9..443a68e2 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMNicConfig.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMNicConfig.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.utils.StringUtils.fmt; @@ -117,6 +120,19 @@ public void setDhcp4Offset(long offset) { lease("dhcp4_lease").set("offset", offset); } + public void setDhcp6Offset(long offset) { + lease("dhcp6_lease").set("offset", offset); + } + + /** + * Turns a static lease off, leaving the offset and the forwards it carried in place. Off + * means the guest takes a dynamic address from the VLAN's pool instead; the settings stay + * stored so putting it back on restores what it was. + */ + public void setDhcpLeaseEnabled(boolean ipv6, boolean enabled) { + lease(ipv6 ? "dhcp6_lease" : "dhcp4_lease").set("enabled", enabled); + } + @NonNull public List getDhcp4Forwards() { return forwards("dhcp4_lease"); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMPeripheralConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMPeripheralConfig.java new file mode 100644 index 00000000..170df6f2 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMPeripheralConfig.java @@ -0,0 +1,226 @@ +// 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 java.util.ArrayList; +import java.util.List; + +import cn.classfun.droidvm.lib.data.HostAudioDevices; +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.enums.Enums; + +/** + * Wrapper over one entry of a VM config's "peripherals" array -- one entry, one guest device. + * + *

    Host endpoints are stored as the stable descriptor from {@code HostAudioDevices} + * ({@code "|

    "}) rather than the numeric AudioDeviceInfo id, because those ids are + * handed out per boot: the same number means a different endpoint, or none, after a reboot or a + * pairing. The label alongside it is only so a row can still name a device that is currently + * unplugged. Resolution to a live id happens in the daemon at VM start.

    + */ +public final class VMPeripheralConfig { + public final DataItem item; + + public VMPeripheralConfig(@NonNull DataItem item) { + this.item = item; + } + + @NonNull + public PeripheralType getType() { + return Enums.optEnum(item, "type", PeripheralType.VIRTIO_SOUND); + } + + public void setType(@NonNull PeripheralType type) { + item.set("type", type); + } + + /** + * Creates the sound card offered by default for a new VM: one playback and one capture + * endpoint, both left to Android's current system routing. + */ + @NonNull + public static VMPeripheralConfig createDefaultVirtioSound() { + var config = new VMPeripheralConfig(DataItem.newObject()); + config.setType(PeripheralType.VIRTIO_SOUND); + var speaker = config.addEndpoint(); + speaker.setMode(SoundMode.SPEAKER); + speaker.setHostDevice(HostAudioDevices.SYSTEM_DEFAULT_KEY, ""); + var microphone = config.addEndpoint(); + microphone.setMode(SoundMode.MICROPHONE); + microphone.setHostDevice(HostAudioDevices.SYSTEM_DEFAULT_KEY, ""); + return config; + } + + // ---- virtio-snd ---- + + /** + * One host endpoint on the card: a direction, and the host device it is pinned to. + * + *

    A card can carry several. What is shared between them lives on the card -- the buffer + * depth and what to do about an underrun are properties of the device's queues, not of any + * one endpoint -- and what distinguishes them lives here.

    + */ + public static final class Endpoint { + public final DataItem item; + + Endpoint(@NonNull DataItem item) { + this.item = item; + } + + @NonNull + public SoundMode getMode() { + return Enums.optEnum(item, "mode", SoundMode.SPEAKER); + } + + public void setMode(@NonNull SoundMode mode) { + item.set("mode", mode); + } + + /** Stable host-device descriptor; see {@code HostAudioDevices.keyOf}. */ + @NonNull + public String getHostDevice() { + var v = item.opt("host_device", (DataItem) null); + return v == null ? "" : v.asString(); + } + + @NonNull + public String getHostLabel() { + var v = item.opt("host_label", (DataItem) null); + return v == null ? "" : v.asString(); + } + + public void setHostDevice(@NonNull String key, @NonNull String label) { + item.set("host_device", key); + item.set("host_label", label); + } + } + + /** The card's endpoints, in the order they are shown and numbered. */ + @NonNull + public List getEndpoints() { + var out = new ArrayList(); + var list = item.opt("endpoints", (DataItem) null); + if (list == null) return out; + for (int i = 0; i < list.size(); i++) { + out.add(new Endpoint(list.opt(i, DataItem.newObject()))); + } + return out; + } + + /** + * Appends an endpoint and returns it. + * + *

    The list is re-read after it is created rather than kept from the local variable: + * {@code set} stores a copy, so appending to the value that was handed to it would build a + * list nothing else can see -- which is what silently swallowed the first endpoint of every + * new card.

    + */ + @NonNull + public Endpoint addEndpoint() { + var list = item.opt("endpoints", (DataItem) null); + if (list == null || !list.is(DataItem.Type.ARRAY)) { + item.set("endpoints", DataItem.newArray()); + list = item.opt("endpoints", (DataItem) null); + } + var endpoint = DataItem.newObject(); + list.append(endpoint); + return new Endpoint(endpoint); + } + + public void removeEndpoint(int index) { + var list = item.opt("endpoints", (DataItem) null); + if (list == null || index < 0 || index >= list.size()) return; + list.remove(index); + } + + @NonNull + public SoundBuffer getBuffer() { + return Enums.optEnum(item, "buffer", SoundBuffer.NORMAL); + } + + public void setBuffer(@NonNull SoundBuffer buffer) { + item.set("buffer", buffer); + } + + @NonNull + public SoundUnderrun getUnderrun() { + return Enums.optEnum(item, "underrun", SoundUnderrun.SILENCE); + } + + public void setUnderrun(@NonNull SoundUnderrun underrun) { + item.set("underrun", underrun); + } + + // ---- host endpoints ---- + + /** + * Stable host-device descriptor for the single endpoint of a one-direction device, or "" + * for "whatever the host would route to anyway". + */ + @NonNull + public String getHostDevice() { + return str("host_device"); + } + + @NonNull + public String getHostLabel() { + return str("host_label"); + } + + public void setHostDevice(@NonNull String key, @NonNull String label) { + item.set("host_device", key); + item.set("host_label", label); + } + + /** Output endpoint of a device that carries both directions (Intel HDA). */ + @NonNull + public String getHostOutDevice() { + return str("host_out_device"); + } + + @NonNull + public String getHostOutLabel() { + return str("host_out_label"); + } + + public void setHostOutDevice(@NonNull String key, @NonNull String label) { + item.set("host_out_device", key); + item.set("host_out_label", label); + } + + /** Input endpoint of a device that carries both directions (Intel HDA). */ + @NonNull + public String getHostInDevice() { + return str("host_in_device"); + } + + @NonNull + public String getHostInLabel() { + return str("host_in_label"); + } + + public void setHostInDevice(@NonNull String key, @NonNull String label) { + item.set("host_in_device", key); + item.set("host_in_label", label); + } + + @NonNull + private String str(@NonNull String key) { + var v = item.optString(key, ""); + return v == null ? "" : v; + } + + /** Wraps every entry of {@code config}'s "peripherals" array, in order. */ + @NonNull + public static List listOf(@NonNull DataItem config) { + var out = new ArrayList(); + var arr = config.opt("peripherals", null); + if (arr == null || !arr.is(DataItem.Type.ARRAY)) return out; + for (var iter : arr) + out.add(new VMPeripheralConfig(iter.getValue())); + return out; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMScreenConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMScreenConfig.java new file mode 100644 index 00000000..9dc67447 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMScreenConfig.java @@ -0,0 +1,681 @@ +// 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 android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import java.util.ArrayList; +import java.util.List; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.enums.Enums; + +/** + * One entry of a VM config's "screens" object -- one screen, and the one exporter bound to it. + * + *

    A screen is a display device the VM has: {@code gpu-0} is the virtio-gpu device's screen, + * {@code simplefb} is the simplefb device's, and the two are independent devices rather than two + * settings of one. The ids are exactly the tokens crosvm's {@code screen=} takes, because they + * are handed to it verbatim; they are also the {@code stable_id} the app stores when the user + * picks a screen to open, so they must never be renamed.

    + * + *

    The entry is keyed by that id rather than sitting in an array, so "one binding per screen" + * -- which crosvm rejects a command line for violating -- cannot be expressed wrongly here in + * the first place.

    + * + *

    {@code enabled} says the VM has that display device at all -- for {@code gpu-0} that is the + * whole virtio-gpu device, renderer included, and the renderer is a setting inside it rather than + * a switch of its own. The exporter says who watches the screen; a screen with no exporter is a + * legal, ordinary state (the guest still has the device), and crosvm accepts it too.

    + */ +public final class VMScreenConfig { + /** Key of the screens object on the VM config. */ + public static final String KEY = "screens"; + + /** The virtio-gpu device's screen. Only exists when the VM has a GPU. */ + public static final String ID_GPU0 = "gpu-0"; + /** The simplefb device's screen; its geometry is fixed by the device tree. */ + public static final String ID_SIMPLEFB = "simplefb"; + /** Every screen id, in the order the UI lists them. Iteration order is part of the schema. */ + public static final String[] IDS = {ID_GPU0, ID_SIMPLEFB}; + + /** Sub-object holding this screen's VNC server settings, when its exporter is VNC. */ + private static final String KEY_VNC = "vnc"; + // The VNC sub-object used to carry an "h264_port" for the H.264 side channel. There is no side + // channel any more -- the stream rides the RFB connection as encoding 50 -- so nothing reads + // the key. A config written before the change still has it, and that is not a migration: + // DataItem returns what it is asked for, so a key nobody asks about is a key that costs + // nothing. What must not happen is the daemon passing it on; see CrosvmBackendInstance, where + // the flag is gone, because the new crosvm refuses to start on a command line that names it. + /** + * Whether this screen gets its own absolute input devices. Absent means on, which is how + * every config written before this key existed keeps the devices it already had: the default + * carries the migration, so nothing on disk has to be rewritten to gain it. + */ + private static final String KEY_INPUT_ENABLED = "input_enabled"; + /** Ceiling on the transport negotiated between this screen and its exporter. */ + private static final String KEY_TRANSPORT_CAP = "transport_cap"; + + // Geometry. Every screen has a size; only the virtio-gpu screen has a mode to put a refresh + // rate and a DPI in, and only simplefb has a poll rate, because that is the difference between + // a device the guest programs and a block of memory the host watches. + private static final String KEY_WIDTH = "width"; + private static final String KEY_HEIGHT = "height"; + private static final String KEY_REFRESH_RATE = "refresh_rate"; + private static final String KEY_DPI_H = "dpi_h"; + private static final String KEY_DPI_V = "dpi_v"; + private static final String KEY_POLL_HZ = "poll_hz"; + + /** The VM-level keys {@link #migrateGeometry} folds into the screens, then drops. */ + private static final String[] LEGACY_GEOMETRY = { + "display_width", "display_height", "display_refresh_rate", "display_dpi_h", "display_dpi_v" + }; + /** + * The VM-level accelerator switch, folded into the gpu-0 screen's own switch by + * {@link #migrateGpuDevice} and then dropped. + */ + private static final String LEGACY_GPU_ENABLED = "gpu_enabled"; + + public static final long DEFAULT_WIDTH = 1280; + public static final long DEFAULT_HEIGHT = 720; + public static final long DEFAULT_REFRESH_RATE = 120; + public static final long DEFAULT_DPI = 160; + /** + * What the simplefb bridge polled at for as long as the rate was not settable, so a screen + * that never names one behaves as it always did. Mirrors crosvm's DEFAULT_SIMPLEFB_POLL_HZ. + */ + public static final long DEFAULT_POLL_HZ = 30; + /** Initial SimpleFB polling rate shown when creating a VM. */ + public static final long NEW_VM_DEFAULT_POLL_HZ = 60; + + /** + * What a screen on a brand-new VM is bound to, in the one place both writers can see it. + * + *

    Two of them write it and they must agree: {@link VMConfig#createWithCustomizeDefaults} + * materialises the config the editor is then handed, and the editor's own screen rows carry a + * default for the case where that config says nothing. They disagreed once -- the config said + * NONE while the row said NATIVE -- and because the config is loaded over the row, turning + * virtio-gpu on showed a screen bound to nothing. Neither side is wrong to have a default; the + * fix is for there to be one value. + * + *

    NATIVE rather than VNC because the viewer for it is this app, already installed: it is + * the only exporter whose first boot can be looked at without setting something up first. This + * is not {@link #getExporter}'s fallback, which answers a different question -- what a stored + * config that does not mention an exporter meant -- and stays NONE. + */ + public static final DisplayExporter NEW_VM_DEFAULT_EXPORTER = DisplayExporter.NATIVE; + /** + * What a brand-new VM's VNC server is told to listen on, in the one place both writers can + * see it -- {@link VMConfig#createWithCustomizeDefaults} and the editor's own screen row -- + * for exactly the reason {@link #NEW_VM_DEFAULT_EXPORTER} is shared: the config is loaded over + * the row, so a second opinion here would be the losing one and the disagreement invisible. + * + *

    The loopback rather than the wildcard. A screen that is reachable from whatever network + * the phone is on the moment the VM first boots is not a default's decision to make; widening + * it is one pick in the host menu, and the app's own console dials the phone itself either + * way.

    + */ + public static final String NEW_VM_DEFAULT_VNC_HOST = "127.0.0.1"; + /** The virtio-gpu screen's port on a new VM: RFB display :0, where a client looks first. */ + public static final long NEW_VM_DEFAULT_VNC_PORT_GPU0 = 5900; + /** + * The simplefb screen's port on a new VM: RFB display :9. + * + *

    Nine displays up rather than one, because the two are defaults and not assignments: both + * screens can be bound to VNC at once, crosvm refuses a command line whose servers share a + * port, and the editor refuses the save before that (see {@code validateNoPortCollision}). The + * gap also leaves :1..:8 to a user handing them out by hand.

    + */ + public static final long NEW_VM_DEFAULT_VNC_PORT_SIMPLEFB = 5909; + public static final long MIN_POLL_HZ = 1; + /** + * crosvm's MAX_SIMPLEFB_POLL_HZ. A sanity bound on a knob whose cost is linear in it, not a + * claim about what a panel can show -- above this the watcher asks for more work than anything + * downstream can use. + */ + public static final long MAX_POLL_HZ = 240; + public final String id; + public final DataItem item; + + public VMScreenConfig(@NonNull String id, @NonNull DataItem item) { + this.id = id; + this.item = item; + } + + /** Whether this screen's display device is configured for the VM. */ + public boolean isEnabled() { + return item.optBoolean("enabled", false); + } + + public void setEnabled(boolean enabled) { + item.set("enabled", enabled); + } + + /** + * Who watches this screen. Absent reads as NONE, and stays reading as NONE even though a new + * screen now comes up defaulted to NATIVE. + * + *

    Those are two different questions and only one of them is here. What a new VM gets is the + * picker's default, set where the row is built; this is what a file that does not say means. + * Nothing this app writes leaves the key out -- {@code ScreenBindingRow.save} calls + * {@link #setExporter} unconditionally on the entry {@link #of} has just created, and + * {@link #migrateBindings} writes it for every config from before the screens split -- so + * moving the fallback would not change what any VM comes up with. It would only change how a + * hand-edited file, or a token this build cannot parse, is read, and it would change it into + * a display service and a pair of input devices bound to a screen whose file never asked for + * either, because everything downstream tests {@code != NONE}.

    + */ + @NonNull + public DisplayExporter getExporter() { + return Enums.optEnum(item, "exporter", DisplayExporter.NONE); + } + + public void setExporter(@NonNull DisplayExporter exporter) { + item.set("exporter", exporter); + } + + /** + * Whether this screen gets its own multi-touch and absolute-pointer devices. + * + *

    Absolute coordinates only mean anything under one output's geometry, so those two + * devices are per screen; the VM-wide keyboard and relative pointer have no output binding at + * all and are unaffected by this switch. Turning it off is turning the two devices off: the + * set of {@code --input} devices is fixed when crosvm starts, so this takes effect on the + * VM's next start, not on the running one.

    + * + *

    Where the screen is exported over VNC the switch means one thing more, without meaning + * anything different: those two devices are half crosvm's now -- it builds that binding's + * tablet and its own keyboard itself -- so the daemon spells the same off/on as + * {@code view-only=true|false} on that screen's {@code --vnc-server}, and off makes crosvm + * build neither and drop RFB pointer and key events. Which is the answer the user was already + * asking for: a screen to watch and not touch, from the app's console and from a third-party + * client alike.

    + * + *

    Defaults to on, and to on for a config that predates the key -- the devices existed + * before it did.

    + */ + public boolean isInputEnabled() { + return item.optBoolean(KEY_INPUT_ENABLED, true); + } + + public void setInputEnabled(boolean enabled) { + item.set(KEY_INPUT_ENABLED, enabled); + } + + /** + * The ceiling on this screen's transport to its exporter; see {@link DisplayTransportCap}. + * + *

    Read against the exporter, because the ladder is the exporter's: a value stored under one + * exporter and read back under another may name a rung that exporter does not have, and the + * answer then is that exporter's default rather than a rung nobody can climb. The stored value + * is left alone -- switching the exporter to look and back must not lose the choice.

    + */ + @NonNull + public DisplayTransportCap getTransportCap() { + var exporter = getExporter(); + var stored = DisplayTransportCap.fromToken(item.optString(KEY_TRANSPORT_CAP, "")); + if (stored != null && DisplayTransportCap.isOfferedFor(id, exporter, stored)) return stored; + return DisplayTransportCap.defaultFor(id, exporter); + } + + public void setTransportCap(@NonNull DisplayTransportCap cap) { + // The lower-case token, not the enum constant: this same string goes on crosvm's command + // line, and one value must not have two spellings. + item.set(KEY_TRANSPORT_CAP, cap.getToken()); + } + + /** This screen's width in pixels. */ + public long getWidth() { + return item.optLong(KEY_WIDTH, DEFAULT_WIDTH); + } + + public void setWidth(long width) { + item.set(KEY_WIDTH, width); + } + + /** This screen's height in pixels. */ + public long getHeight() { + return item.optLong(KEY_HEIGHT, DEFAULT_HEIGHT); + } + + public void setHeight(long height) { + item.set(KEY_HEIGHT, height); + } + + /** + * The mode's refresh rate, in Hz. Virtio-GPU only: it reaches the guest through the scanout's + * mode, and simplefb has no mode to carry one -- the device tree says nothing about time. + */ + public long getRefreshRate() { + return item.optLong(KEY_REFRESH_RATE, DEFAULT_REFRESH_RATE); + } + + public void setRefreshRate(long hz) { + item.set(KEY_REFRESH_RATE, hz); + } + + /** Horizontal DPI of the mode. Virtio-GPU only, for the same reason as the refresh rate. */ + public long getDpiH() { + return item.optLong(KEY_DPI_H, DEFAULT_DPI); + } + + public void setDpiH(long dpi) { + item.set(KEY_DPI_H, dpi); + } + + /** Vertical DPI of the mode. Virtio-GPU only. */ + public long getDpiV() { + return item.optLong(KEY_DPI_V, DEFAULT_DPI); + } + + public void setDpiV(long dpi) { + item.set(KEY_DPI_V, dpi); + } + + /** + * How many times a second the host looks at the simplefb framebuffer -- this screen's answer + * to "refresh rate", and the only one it can give. + * + *

    Nothing announces a frame here: the guest maps the region write-combining and no write + * traps, so the rate the host samples at is the only thing that decides when a picture exists. + * It is a property of the host's watcher, not of the device the guest sees -- the guest cannot + * tell what it is set to -- which is why it lives on this screen and the GPU screen's refresh + * rate, a real mode field the guest is told about, does not.

    + */ + public long getPollHz() { + return item.optLong(KEY_POLL_HZ, DEFAULT_POLL_HZ); + } + + public void setPollHz(long hz) { + item.set(KEY_POLL_HZ, hz); + } + + /** True for the screen the virtio-gpu device provides, as opposed to simplefb's. */ + public boolean isGpu() { + return ID_GPU0.equals(id); + } + + /** The port a brand-new VM's [id] screen is given; see the two constants it picks between. */ + public static long newVmDefaultVncPort(@NonNull String id) { + return ID_GPU0.equals(id) + ? NEW_VM_DEFAULT_VNC_PORT_GPU0 : NEW_VM_DEFAULT_VNC_PORT_SIMPLEFB; + } + + /** This screen's VNC sub-object, created if it is not there yet. */ + @NonNull + public DataItem vnc() { + var vnc = item.opt(KEY_VNC, (DataItem) null); + if (vnc == null || !vnc.is(DataItem.Type.OBJECT)) { + item.set(KEY_VNC, DataItem.newObject()); + vnc = item.get(KEY_VNC); + } + return vnc; + } + + /** Listen address; empty means crosvm's own default (the IPv4 wildcard). */ + @NonNull + public String getVncHost() { + return str(vnc().optString("host", "")); + } + + public void setVncHost(@NonNull String host) { + vnc().set("host", host); + } + + /** Listen port, or -1 when one has not been assigned yet (the daemon picks one on start). */ + public long getVncPort() { + return vnc().optLong("port", -1); + } + + public void setVncPort(long port) { + vnc().set("port", port); + } + + public boolean isVncPasswordAuth() { + return vnc().optBoolean("password_auth", false); + } + + public void setVncPasswordAuth(boolean auth) { + vnc().set("password_auth", auth); + } + + @NonNull + public String getVncPassword() { + return str(vnc().optString("password", "")); + } + + public void setVncPassword(@NonNull String password) { + vnc().set("password", password); + } + + @StringRes + public int getNameStringId() { + return isGpu() ? R.string.create_vm_screen_gpu0 : R.string.create_vm_screen_simplefb; + } + + /** Human-facing name of the screen: "Virtio-GPU screen", "SimpleFB screen". */ + @NonNull + public String getDisplayName(@NonNull Context ctx) { + return ctx.getString(getNameStringId()); + } + + @NonNull + private static String str(@Nullable String s) { + return s == null ? "" : s; + } + + /** The screens object on {@code config}, created if absent. */ + @NonNull + private static DataItem screensOf(@NonNull DataItem config) { + var screens = config.opt(KEY, (DataItem) null); + if (screens == null || !screens.is(DataItem.Type.OBJECT)) { + config.set(KEY, DataItem.newObject()); + screens = config.get(KEY); + } + return screens; + } + + /** The entry for [id], created empty if the config has none yet. */ + @NonNull + public static VMScreenConfig of(@NonNull DataItem config, @NonNull String id) { + var screens = screensOf(config); + var entry = screens.opt(id, (DataItem) null); + if (entry == null || !entry.is(DataItem.Type.OBJECT)) { + screens.set(id, DataItem.newObject()); + entry = screens.get(id); + } + return new VMScreenConfig(id, entry); + } + + /** The entry for [id], or null when the config does not describe that screen. */ + @Nullable + public static VMScreenConfig find(@NonNull DataItem config, @NonNull String id) { + var screens = config.opt(KEY, (DataItem) null); + if (screens == null || !screens.is(DataItem.Type.OBJECT)) return null; + var entry = screens.opt(id, (DataItem) null); + if (entry == null || !entry.is(DataItem.Type.OBJECT)) return null; + return new VMScreenConfig(id, entry); + } + + /** + * Whether the VM has the virtio-gpu device at all -- which is exactly the gpu-0 screen's + * switch, because the switch is the device. + * + *

    There used to be a second answer, a VM-level {@code gpu_enabled}, and the pair could + * disagree: a GPU with the screen off was meant to be a renderer that scans out nothing. It + * never worked -- no desktop ever came up on it -- so the two collapsed into one, and the + * renderer became a setting inside the device rather than a switch beside it. Everything that + * used to ask "is there a GPU" asks here.

    + */ + public static boolean hasGpuDevice(@NonNull DataItem config) { + var gpu0 = find(config, ID_GPU0); + return gpu0 != null && gpu0.isEnabled(); + } + + /** Every screen that exists and has an exporter bound to it, in {@link #IDS} order. */ + @NonNull + public static List boundOf(@NonNull DataItem config) { + var out = new ArrayList(); + for (var screen : listOf(config)) + if (screen.isEnabled() && screen.getExporter() != DisplayExporter.NONE) + out.add(screen); + return out; + } + + /** + * Whether a binding to [exporter], capped at [ceiling], is one the host might blit for. + * + *

    The two exporters answer differently, and it is not a leftover asymmetry. The native + * display's bridge is pointed at a driver whatever the ceiling says -- naming one for a VM + * that will not blit costs nothing, since a capped binding never dlopens it, while failing to + * name one costs the GPU path in silence. VNC's sink reaches the same driver only where its + * ceiling leaves the GPU rung available, because the CPU cap on a VNC binding is the one thing + * a user sets to mean "do not blit this screen at all".

    + * + *

    Which is why the test is "not the bottom rung" rather than a list of rungs: the encoder + * ceiling blits too -- it is the same blit with the encoder's input surface as its destination + * -- so it needed no clause of its own here, and would have been silently missed by a + * predicate that named {@link DisplayTransportCap#GPU} outright.

    + */ + public static boolean isGpuBlitBinding(@NonNull DisplayExporter exporter, + @NonNull DisplayTransportCap ceiling) { + switch (exporter) { + case NATIVE: + return true; + case VNC: + return ceiling != DisplayTransportCap.CPU; + default: + return false; + } + } + + /** This screen's own answer: the device is there, something is watching it, and it may blit. */ + public boolean hasGpuBlitBinding() { + return isEnabled() && isGpuBlitBinding(getExporter(), getTransportCap()); + } + + /** + * Whether any screen this VM has could run a GPU pipeline to its exporter. + * + *

    For the host-process settings that belong to that path rather than to one screen -- the + * Vulkan library the display bridge dlopens is the one -- because an environment variable is + * process-wide and cannot be set per screen even when the thing it configures runs per screen. + * Which is why the question has to be asked of all of them, and why it is not "has this VM a + * native binding": both sinks dlopen that same driver now, the VNC one to blit into a headless + * target instead of a Surface, so a VM whose only binding is a VNC screen at the GPU rung needs + * the env exactly as much as one exported natively.

    + */ + public static boolean hasGpuBlitBinding(@NonNull DataItem config) { + for (var screen : boundOf(config)) + if (screen.hasGpuBlitBinding()) return true; + return false; + } + + /** + * Whether this screen gets its own {@code multi-touch} + absolute-pointer pair when the VM + * starts: the device has to exist, something has to be watching it, and the switch has to be + * on. A screen nobody exports has no console to send absolute input from, so devices for it + * would be devices nothing can ever write to. + * + *

    Both devices, but not both from here: on a VNC-exported screen this is what the daemon + * turns into {@code view-only=false}, and crosvm builds the tablet. Which half is whose is + * {@code CrosvmBackendInstance.touchscreenScreens}/{@code socketTabletScreens}; this predicate + * is the same question for both and stays one.

    + */ + public boolean hasAbsoluteInput() { + return isEnabled() && getExporter() != DisplayExporter.NONE && isInputEnabled(); + } + + /** Every screen that gets its own absolute input devices, in {@link #IDS} order. */ + @NonNull + public static List absoluteInputOf(@NonNull DataItem config) { + var out = new ArrayList(); + for (var screen : listOf(config)) + if (screen.hasAbsoluteInput()) out.add(screen); + return out; + } + + /** Every screen the config describes, in {@link #IDS} order. */ + @NonNull + public static List listOf(@NonNull DataItem config) { + var out = new ArrayList(); + for (var id : IDS) { + var screen = find(config, id); + if (screen != null) out.add(screen); + } + return out; + } + + /** + * Brings a config up to the current screens schema. Two folds, each gated on its own evidence + * that it has not run yet, so a config from any generation lands in the same place. + */ + static void migrate(@NonNull DataItem config) { + // Asked before anything runs, because the first fold is what makes it false. + var preScreens = config.opt(KEY, (DataItem) null) == null; + migrateBindings(config); + migrateGeometry(config); + // Last, because the two above still read gpu_enabled to work out what the old config + // meant, and this is the one that takes it away. + migrateGpuDevice(config, preScreens); + } + + /** + * Folds the legacy VM-level display keys into per-screen bindings, once, on load. + * + *

    The old model had one display: {@code display_backend} chose which device produced it + * and {@code native_display_enabled} / {@code vnc_enabled} chose who consumed it, with the + * two consumers mutually exclusive because crosvm kept whichever sink opened first. The new + * model has two independent devices, each carrying its own binding, so the enum becomes two + * enables and the consumer booleans become one exporter per screen.

    + * + *

    Unlike {@link VMConfig#migrateBoot} this drops the legacy keys rather than leaving them + * for an older build to read: they no longer describe a state this schema can be in (two + * screens bound at once has no {@code display_backend} value), so leaving them would leave a + * second, disagreeing answer on disk for the same question.

    + */ + private static void migrateBindings(@NonNull DataItem config) { + if (config.opt(KEY, (DataItem) null) != null) return; // already the new shape + + var displayEnabled = config.optBoolean("display_enabled", false); + var backend = Enums.optEnum(config, "display_backend", DisplayBackend.NONE); + var wantNative = config.optBoolean("native_display_enabled", false); + var wantVnc = config.optBoolean("vnc_enabled", false); + var gpuEnabled = config.optBoolean(LEGACY_GPU_ENABLED, false); + + // Which screen the old config named. display_enabled gated everything, so a VM with it + // off named no screen no matter what the backend said. + String bound = null; + if (displayEnabled && backend == DisplayBackend.VIRTIO_GPU) bound = ID_GPU0; + else if (displayEnabled && backend == DisplayBackend.SIMPLEFB) bound = ID_SIMPLEFB; + + // `--vnc-server` was emitted from vnc_enabled alone -- buildVncCommand never looked at + // display_enabled -- so "display off, VNC on, GPU on" was a working VM: crosvm gave the + // GPU device its default display and the VNC server showed it. Keep it working by + // binding to the screen crosvm's own compat rule would have picked for an exporter that + // named none: gpu-0 when there is a GPU. With no GPU there was no device to open and the + // server never served anything, so there is nothing to carry over. + if (bound == null && wantVnc && !wantNative && gpuEnabled) bound = ID_GPU0; + + var gpu0 = of(config, ID_GPU0); + var fb = of(config, ID_SIMPLEFB); + gpu0.setEnabled(ID_GPU0.equals(bound)); + fb.setEnabled(ID_SIMPLEFB.equals(bound)); + gpu0.setExporter(DisplayExporter.NONE); + fb.setExporter(DisplayExporter.NONE); + + var exporter = wantNative ? DisplayExporter.NATIVE + : wantVnc ? DisplayExporter.VNC : DisplayExporter.NONE; + if (bound != null && exporter != DisplayExporter.NONE) + of(config, bound).setExporter(exporter); + + // The VNC settings follow the binding, and when nothing is bound they still have to land + // somewhere or a port and a password the user chose are lost. Park them on the screen the + // old backend named, or -- with no backend at all -- on the one crosvm would have + // defaulted an unscreened exporter to, so re-enabling the exporter finds them in place. + var vncHome = bound != null ? bound : gpuEnabled ? ID_GPU0 : ID_SIMPLEFB; + var home = of(config, vncHome); + home.setVncHost(str(config.optString("vnc_host", ""))); + home.setVncPort(config.optLong("vnc_port", -1)); + home.setVncPasswordAuth(config.optBoolean("vnc_password_auth", false)); + home.setVncPassword(str(config.optString("vnc_password", ""))); + + config.remove("display_enabled"); + config.remove("display_backend"); + config.remove("native_display_enabled"); + config.remove("vnc_enabled"); + config.remove("vnc_host"); + config.remove("vnc_port"); + config.remove("vnc_password_auth"); + config.remove("vnc_password"); + } + + /** + * Folds the five flat geometry keys onto the screens that have somewhere to put them. + * + *

    The geometry was VM-level because the display was: one size, one refresh rate, one DPI, + * whichever device was producing the picture. With two devices that is one number answering + * two questions -- a 1400x1050 virtio-gpu mode and a 1280x720 framebuffer are an ordinary + * pair, and the old schema could not say it. Both screens inherit the old values, so a VM + * that is migrated and not touched comes up exactly as it did.

    + * + *

    What does not carry over is what the flat keys could not hold. simplefb gets a poll rate, + * written out at the rate the bridge used for as long as it was not settable; the refresh rate + * and DPI go only to the GPU screen, because simplefb's geometry is fixed by the device tree + * and the device tree describes neither.

    + * + *

    Gated on the legacy keys still being there rather than on the new ones being absent: the + * new ones have defaults equal to the old ones, so "absent" is indistinguishable from "folded + * and left at the default", and gating on that would re-run the fold over an edited screen. + * Dropping the keys is what makes this run once, for the same reason the bindings fold drops + * its own -- a second answer on disk for the same question is worse than no answer.

    + */ + private static void migrateGeometry(@NonNull DataItem config) { + var legacy = false; + for (var key : LEGACY_GEOMETRY) + if (config.opt(key, (DataItem) null) != null) { + legacy = true; + break; + } + if (!legacy) return; + + var width = config.optLong("display_width", DEFAULT_WIDTH); + var height = config.optLong("display_height", DEFAULT_HEIGHT); + + var gpu0 = of(config, ID_GPU0); + gpu0.setWidth(width); + gpu0.setHeight(height); + gpu0.setRefreshRate(config.optLong("display_refresh_rate", DEFAULT_REFRESH_RATE)); + gpu0.setDpiH(config.optLong("display_dpi_h", DEFAULT_DPI)); + gpu0.setDpiV(config.optLong("display_dpi_v", DEFAULT_DPI)); + + var fb = of(config, ID_SIMPLEFB); + fb.setWidth(width); + fb.setHeight(height); + // Written rather than left to the getter's default: this screen gaining a rate of its own + // is the visible half of the fold, and a value nobody can read out of the file is one the + // user cannot be shown having inherited. + fb.setPollHz(DEFAULT_POLL_HZ); + + for (var key : LEGACY_GEOMETRY) config.remove(key); + } + + /** + * Folds the VM-level {@code gpu_enabled} away: the gpu-0 switch is the virtio-gpu device now, + * and the renderer is a setting inside it. + * + *

    The old pair was two answers to one question and they were allowed to disagree, so the + * fold has to decide what each disagreement meant.

    + * + *

    Accelerator on, screen off. This was supposed to be a GPU that renders and scans + * out nothing, and it is the shape an acceleration test gets saved in. It never worked -- no + * desktop ever came up on such a VM -- so there is nothing to preserve: the device is off. A + * VM that was in that state was not doing anything, and saying so is more honest than carrying + * a configuration forward on the grounds that it was written down.

    + * + *

    Accelerator off, screen on. The reverse disagreement was a real, ordinary VM in + * intent -- a display with no 3D -- and the old builder emitted no {@code --gpu} at all for + * it, so it had no display device either. The new model can say exactly what was meant, so it + * does: the device is on with the 2D renderer, which is also what repairs the VM.

    + * + *

    Runs for any config from before the screens split, key or no key: an absent + * {@code gpu_enabled} read as false is exactly the second case, and gating on the key alone + * would leave those configs with a virtio-gpu screen and no renderer named.

    + */ + private static void migrateGpuDevice(@NonNull DataItem config, boolean preScreens) { + if (!preScreens && config.opt(LEGACY_GPU_ENABLED, (DataItem) null) == null) return; + var accelerated = config.optBoolean(LEGACY_GPU_ENABLED, false); + var gpu0 = find(config, ID_GPU0); + if (!accelerated && gpu0 != null && gpu0.isEnabled()) + // Whatever gpu_backend said was inert while the accelerator was off -- the old builder + // never read it -- so this is naming the renderer the VM actually ran, not losing one. + config.set("gpu_backend", GpuBackend.GPU_2D); + config.remove(LEGACY_GPU_ENABLED); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMSerialConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMSerialConfig.java new file mode 100644 index 00000000..5dd8b299 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMSerialConfig.java @@ -0,0 +1,182 @@ +// 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 android.content.Context; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.List; + +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.enums.Enums; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +/** + * Wrapper over one entry of a VM config's "serial_ports" array -- one entry, one guest serial + * port. + * + *

    Configs from before this array existed carry no entries at all, and their VMs behaved as + * "COM1 is the app console, COM2-4 are sinks" (that was hard-coded in the backend). So that is + * exactly what {@link #ensureDefaults} materializes: readers must call it (or accept the same + * defaults) rather than treating a missing array as "no serial ports".

    + */ +public final class VMSerialConfig { + /** Key of the array on the VM config. */ + public static final String KEY = "serial_ports"; + + public final DataItem item; + + public VMSerialConfig(@NonNull DataItem item) { + this.item = item; + } + + @NonNull + public SerialHardware getHardware() { + return Enums.optEnum(item, "hardware", SerialHardware.SERIAL); + } + + public void setHardware(@NonNull SerialHardware hardware) { + item.set("hardware", hardware); + } + + /** 1-based port number within its hardware kind (crosvm's {@code num=}). */ + public int getNum() { + return (int) item.optLong("num", 1); + } + + public void setNum(int num) { + item.set("num", num); + } + + @NonNull + public SerialBackend getBackend() { + return Enums.optEnum(item, "backend", SerialBackend.SINK); + } + + public void setBackend(@NonNull SerialBackend backend) { + item.set("backend", backend); + } + + /** Host path for path-based backends; for PTY, the optional stable symlink to the slave. */ + @NonNull + public String getPath() { + var v = item.optString("path", ""); + return v == null ? "" : v; + } + + public void setPath(@NonNull String path) { + item.set("path", path); + } + + /** + * Whether this port is the guest console -- the one the firmware's SPCR points at (which + * is what Windows EMS/SAC attaches to) and the one earlycon lands on. Explicit and + * single-select in the UI, because "first interactive port wins" made the fixed COM + * quartet unbeatable: an SBSA port could never take the console without sinking COM1 and + * losing its firmware-log tab. Configs from before this key have it on no port; readers + * fall back to the historical first-interactive rule so their behavior is unchanged. + */ + public boolean isConsole() { + return item.optBoolean("console", false); + } + + public void setConsole(boolean console) { + item.set("console", console); + } + + /** + * Which USB ACM pool slot this port attaches to ({@link SerialBackend#USB_ACM} only). + * Part of the config on purpose: first-free allocation would let boot order decide which + * host COM port a VM lands on. + */ + public int getUsbSlot() { + return (int) item.optLong("usb_slot", 0); + } + + public void setUsbSlot(int slot) { + item.set("usb_slot", slot); + } + + /** True for the built-in 16550 quartet: backend is editable, the row is not removable. */ + public boolean isFixed() { + return item.optBoolean("fixed", false); + } + + public void setFixed(boolean fixed) { + item.set("fixed", fixed); + } + + /** + * Console-stream name for this port when its backend is {@link SerialBackend#APP_CONSOLE}. + * Doubles as the stable identity the console UI shows, so it is defined here rather than in + * the daemon. + */ + @NonNull + public String getStreamName() { + switch (getHardware()) { + case SBSA: return fmt("sbsa%d", getNum()); + case VIRTIO_CONSOLE: return fmt("vcon%d", getNum()); + case SERIAL: + default: return fmt("serial%d", getNum()); + } + } + + /** Human-facing name of the port itself: "Serial 1", "SBSA 1", ... */ + @NonNull + public String getDisplayName(@NonNull Context ctx) { + return fmt("%s %d", getHardware().getDisplayString(ctx), getNum()); + } + + /** Wraps every entry of {@code config}'s "serial_ports" array, in order. */ + @NonNull + public static List listOf(@NonNull DataItem config) { + var out = new ArrayList(); + var arr = config.opt(KEY, (DataItem) null); + if (arr == null || !arr.is(DataItem.Type.ARRAY)) return out; + for (var iter : arr) + out.add(new VMSerialConfig(iter.getValue())); + return out; + } + + /** + * Makes {@code config}'s serial list explicit, preserving what older configs meant. + * + *

    A missing/invalid array becomes the historical layout: COM1 as the app console, COM2-4 + * as sinks. An array that exists but is missing one of the fixed COM ports (a config saved + * by a build with fewer of them, or hand-edited) gets the missing ones appended as sinks. + * Existing entries are never touched.

    + */ + public static void ensureDefaults(@NonNull DataItem config) { + var arr = config.opt(KEY, (DataItem) null); + if (arr == null || !arr.is(DataItem.Type.ARRAY)) { + config.set(KEY, DataItem.newArray()); + arr = config.opt(KEY, (DataItem) null); + } + var have = new boolean[SerialHardware.SERIAL.getMaxPorts() + 1]; + var haveConsole = false; + for (var iter : arr) { + var port = new VMSerialConfig(iter.getValue()); + var num = port.getNum(); + if (port.getHardware() == SerialHardware.SERIAL + && num >= 1 && num < have.length) have[num] = true; + if (port.isConsole()) haveConsole = true; + } + for (int num = 1; num < have.length; num++) { + if (have[num]) continue; + var entry = DataItem.newObject(); + arr.append(entry); + var port = new VMSerialConfig(entry); + port.setHardware(SerialHardware.SERIAL); + port.setNum(num); + port.setBackend(num == 1 ? SerialBackend.APP_CONSOLE : SerialBackend.SINK); + // The console is single-select: a config that already names one -- a shipped + // template whose SBSA carries it -- must not gain a second flag here. + if (num == 1 && !haveConsole) port.setConsole(true); + port.setFixed(true); + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMState.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMState.java index 291ff685..7c127e26 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMState.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMState.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 androidx.annotation.ColorRes; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMStore.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMStore.java index 06dd953d..dcf25f41 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMStore.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VMStore.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 android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VpuConfig.java b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VpuConfig.java new file mode 100644 index 00000000..5a0c53c0 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/store/vm/VpuConfig.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.Nullable; + +import cn.classfun.droidvm.lib.store.base.DataItem; + +/** + * Whether a VM is given hardware video acceleration. + * + *

    One switch for both directions. In the guest they are two devices -- a virtio-media device is + * one V4L2 node is one function, so a decoder and an encoder cannot share one -- but they are one + * piece of hardware to whoever ticks the box, and there is no host on which one would work and the + * other would not.

    + * + *

    Stored but not yet acted on: crosvm carries no virtio-media codec device, so the backends + * read this and attach nothing. It lives here rather than in the tab so that the day the device + * lands, the config it needs is already in every VM that asked for it.

    + */ +public final class VpuConfig { + public static final String KEY_ENABLED = "vpu_enabled"; + public static final String KEY_HOST_POOL_MB = "vpu_host_pool_mb"; + public static final String KEY_GUEST_POOL_MB = "vpu_guest_pool_mb"; + + public static final int DEFAULT_HOST_POOL_MB = 256; + public static final int DEFAULT_GUEST_POOL_MB = 128; + + private VpuConfig() { + } + + public static boolean isEnabled(@NonNull DataItem config) { + return config.optBoolean(KEY_ENABLED, false); + } + + public static void setEnabled(@NonNull DataItem config, boolean enabled) { + config.set(KEY_ENABLED, enabled); + } + + /** + * Where the host puts the buffers it allocates for the guest to map. + * + *

    Always present: host-allocated buffers are how virtio-media works on every hypervisor, + * and the pool is only a different base for the offset the host already returns.

    + */ + public static int getHostPoolMb(@NonNull DataItem config) { + return (int) config.optLong(KEY_HOST_POOL_MB, DEFAULT_HOST_POOL_MB); + } + + public static void setHostPoolMb(@NonNull DataItem config, int mb) { + config.set(KEY_HOST_POOL_MB, mb); + } + + /** The stored guest pool size, whether or not this VM can use one. */ + public static int getGuestPoolMb(@NonNull DataItem config) { + return (int) config.optLong(KEY_GUEST_POOL_MB, DEFAULT_GUEST_POOL_MB); + } + + public static void setGuestPoolMb(@NonNull DataItem config, int mb) { + config.set(KEY_GUEST_POOL_MB, mb); + } + + /** + * Whether a guest-side pool means anything for {@code pvm}. + * + *

    It only does when the host cannot read guest memory. Everywhere else the guest driver's + * ordinary allocation is already reachable, and declaring a pool would replace a working path + * with a bounded one for no gain. The value stays in the config either way, so switching the + * protection mode back does not lose it.

    + */ + public static boolean guestPoolApplies(@Nullable ProtectedVM pvm) { + return pvm == ProtectedVM.PROTECTED_PROTECTED + || pvm == ProtectedVM.PROTECTED_WITHOUT_FIRMWARE; + } + + /** + * The guest pool size to pass to crosvm, or 0 for "do not create one". + * + *

    0 is not a smaller pool, it is no {@code media_guest} node at all: with nothing in + * /reserved-memory to find, the guest driver falls back to its stock behaviour of allocating + * from system RAM.

    + */ + public static int guestPoolMbFor(@NonNull DataItem config, @Nullable ProtectedVM pvm) { + return guestPoolApplies(pvm) ? getGuestPoolMb(config) : 0; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/AdaptiveTabLayout.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/AdaptiveTabLayout.java new file mode 100644 index 00000000..324fc741 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/AdaptiveTabLayout.java @@ -0,0 +1,91 @@ +// 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.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.tabs.TabLayout; + +/** + * A tab bar that spreads its tabs evenly across the full width while they fit, and falls back + * to natural-width scrollable tabs when they do not. + * + *

    Material offers either behaviour but never both: {@code MODE_FIXED} always fills and never + * scrolls, so long labels get squeezed, while {@code MODE_AUTO} equalises the tabs to the widest + * one and centres them, leaving the rest of the bar empty. This picks between the two on every + * measure pass instead. + * + *

    Set {@code app:tabMinWidth} in the layout: Material's own default is 72dp on phones but + * 160dp under {@code sw600dp}, which is wide enough to push a handful of short tabs off a tablet + * screen. + */ +public final class AdaptiveTabLayout extends TabLayout { + + public AdaptiveTabLayout(@NonNull Context context) { + super(context); + } + + public AdaptiveTabLayout(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + } + + public AdaptiveTabLayout( + @NonNull Context context, + @Nullable AttributeSet attrs, + int defStyleAttr + ) { + super(context, attrs, defStyleAttr); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED) { + int available = + MeasureSpec.getSize(widthMeasureSpec) - getPaddingLeft() - getPaddingRight(); + int widest = widestTabWidth(); + // Filling gives every tab the same width, so the widest label is what decides + // whether spreading them out would squeeze anything. + if (widest > 0 && available > 0) + setSpreadTabs(widest * getTabCount() <= available); + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec); + } + + /** + * Width of the widest tab at its natural size. Measured with an unspecified spec, so the + * answer does not depend on the mode currently in effect and the choice cannot oscillate. + */ + private int widestTabWidth() { + View strip = getChildCount() > 0 ? getChildAt(0) : null; + if (!(strip instanceof ViewGroup)) return 0; + var tabs = (ViewGroup) strip; + int unspecified = MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED); + int widest = 0; + for (int i = 0; i < tabs.getChildCount(); i++) { + View tab = tabs.getChildAt(i); + if (tab.getVisibility() == GONE) continue; + tab.measure(unspecified, unspecified); + widest = Math.max(widest, tab.getMeasuredWidth()); + } + return widest; + } + + private void setSpreadTabs(boolean spread) { + int mode = spread ? MODE_FIXED : MODE_SCROLLABLE; + int gravity = spread ? GRAVITY_FILL : GRAVITY_START; + if (getTabMode() == mode && getTabGravity() == gravity) return; + // Material rejects MODE_SCROLLABLE + GRAVITY_FILL and MODE_FIXED + GRAVITY_START with a + // warning, and both setters apply the pair immediately. GRAVITY_CENTER is valid with + // either mode, so step through it to keep every intermediate state a supported one. + setTabGravity(GRAVITY_CENTER); + setTabMode(mode); + setTabGravity(gravity); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/BackAskHelper.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/BackAskHelper.java index e725862e..1e422b98 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/BackAskHelper.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/BackAskHelper.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.ui; import androidx.activity.OnBackPressedCallback; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/CameraPermission.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/CameraPermission.java new file mode 100644 index 00000000..c4ffede6 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/CameraPermission.java @@ -0,0 +1,93 @@ +// 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.ui; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.widget.Toast; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import cn.classfun.droidvm.R; + +/** + * Asks for CAMERA before a VM is given a camera peripheral, and refuses to add one without it. + * + *

    Unlike {@link RecordAudioPermission}, which runs its action whether or not the user agrees, + * a denial here stops the peripheral being added. That is not a policy preference, it is what the + * platform does: CAMERA is a foreground-only runtime permission, so without the grant AppOps + * resolves the camera op to MODE_IGNORED and {@code ACameraManager_openCamera} fails with + * ERROR_CAMERA_DISABLED -- measured, not assumed. A camera device added without the grant is a + * device that can only ever fail to open, and a VM whose config lists hardware it will never get + * is exactly what {@code PeripheralType.isAvailable} exists to avoid.

    + * + *

    The grant is necessary but not sufficient: the uid also has to be in a foreground state when + * the guest actually opens the camera, which is what the peripheral foreground service is for. + * See {@code PeripheralType.needsForegroundService}.

    + * + *

    Construct from an activity's {@code onCreate}: the result launcher has to be registered + * before the activity reaches STARTED.

    + */ +public final class CameraPermission { + private final AppCompatActivity activity; + private final ActivityResultLauncher launcher; + private Runnable pending; + + public CameraPermission(@NonNull AppCompatActivity activity) { + this.activity = activity; + this.launcher = activity.registerForActivityResult( + new ActivityResultContracts.RequestPermission(), granted -> { + var action = pending; + pending = null; + if (granted && action != null) { + action.run(); + } else if (!granted) { + // Nothing was added; say so, because an add that quietly does nothing reads + // as a broken button rather than as a refusal being honoured. + Toast.makeText(activity, R.string.camera_permission_declined, + Toast.LENGTH_SHORT).show(); + } + }); + } + + /** + * Runs {@code action} only once the camera is allowed: immediately when the grant is already + * held, after the user agrees otherwise, and never if they decline. + * + *

    Straight to the platform dialog, with no rationale of our own in front of it. The + * platform's own wording already says which app is asking and for what, so a rationale first + * is two dialogs asking one question. It is shown only on a second attempt, where the user + * has declined once and the platform will not ask again unaided.

    + */ + public void requireThen(@NonNull Runnable action) { + if (granted()) { + action.run(); + return; + } + pending = action; + if (activity.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) { + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.camera_permission_title) + .setMessage(R.string.camera_permission_message) + .setPositiveButton(R.string.camera_permission_allow, + (d, w) -> launcher.launch(Manifest.permission.CAMERA)) + .setNegativeButton(R.string.camera_permission_cancel, (d, w) -> pending = null) + .setOnCancelListener(d -> pending = null) + .show(); + return; + } + launcher.launch(Manifest.permission.CAMERA); + } + + public boolean granted() { + return ContextCompat.checkSelfPermission(activity, + Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/CopyableField.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/CopyableField.java index 2c7dcb84..988c8c89 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/CopyableField.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/CopyableField.java @@ -1,8 +1,13 @@ +// 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.ui; import android.content.ClipData; +import android.content.ClipDescription; import android.content.ClipboardManager; import android.content.Context; +import android.os.PersistableBundle; import android.view.View; import android.view.ViewParent; import android.widget.EditText; @@ -69,6 +74,19 @@ public static void copy( Toast.makeText(ctx, R.string.field_copied, Toast.LENGTH_SHORT).show(); } + /** Like {@link #copy}, but flags the clip sensitive so previews stay masked. */ + public static void copySensitive( + @NonNull Context ctx, @NonNull CharSequence text, @NonNull CharSequence label) { + var cm = ctx.getSystemService(ClipboardManager.class); + if (cm == null) return; + var clip = ClipData.newPlainText(label, text); + var extras = new PersistableBundle(); + extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true); + clip.getDescription().setExtras(extras); + cm.setPrimaryClip(clip); + Toast.makeText(ctx, R.string.field_copied, Toast.LENGTH_SHORT).show(); + } + @Nullable private static TextInputLayout enclosingLayout(@NonNull View v) { ViewParent p = v.getParent(); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/DragTouchListener.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/DragTouchListener.java index 67c2d5e7..34e46dd3 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/DragTouchListener.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/DragTouchListener.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.ui; import static java.lang.Math.hypot; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/IconItemAdapter.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/IconItemAdapter.java index 2a62a972..51ecc3a2 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/IconItemAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/IconItemAdapter.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.ui; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/ImeInsetsApplier.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/ImeInsetsApplier.java index 948b4952..29d99f82 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/ImeInsetsApplier.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/ImeInsetsApplier.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.ui; import android.app.Activity; @@ -21,6 +24,11 @@ * applies the system bar insets, so without this the keyboard covers whatever * the user is typing into. * + *

    Activities use {@code windowSoftInputMode="adjustNothing"}: allowing the + * framework to resize the window as well as applying this inset can move the + * content twice on devices that still honor {@code adjustResize} under + * edge-to-edge. + * *

    This applier pads the bottom of every activity's content view by the IME * inset, lifting the content (and any scroll container within it) above the * keyboard for every activity uniformly. Activities that handle the keyboard diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/ImeInsetsExempt.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/ImeInsetsExempt.java index a78ec638..c01cd2b8 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/ImeInsetsExempt.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/ImeInsetsExempt.java @@ -1,8 +1,11 @@ +// 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.ui; /** * Marker for activities that manage the soft keyboard themselves (e.g. the - * terminal/serial console and VNC display, where the IME must overlay the + * VNC and native display activities, where the IME must overlay the * full-screen surface rather than resize it). * *

    {@link ImeInsetsApplier} skips any activity implementing this interface. diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/MaterialMenu.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/MaterialMenu.java index 2ac024de..fc4e6e1b 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/MaterialMenu.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/MaterialMenu.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.ui; import android.content.Context; @@ -39,6 +42,7 @@ public final class MaterialMenu { private final ListPopupWindow popup; private MenuItem.OnMenuItemClickListener listener; private int gravity = Gravity.NO_GRAVITY; + private View headerView; public MaterialMenu(@NonNull Context context, @NonNull View anchor) { this.context = context; @@ -71,6 +75,19 @@ public void setOnMenuItemClickListener( this.listener = listener; } + /** + * Custom view rendered above the menu list inside the popup (ListPopupWindow prompt view) - + * e.g. a segmented input-mode selector. Set before {@link #show()}. + */ + public void setHeaderView(@Nullable View header) { + this.headerView = header; + } + + /** Closes the popup; header-view controls use this after handling a selection. */ + public void dismiss() { + popup.dismiss(); + } + @SuppressWarnings("unused") public void setGravity(int gravity) { this.gravity = gravity; @@ -104,6 +121,15 @@ private int preparePopup() { var adapter = new MenuAdapter(context, items, hasIcons); popup.setAdapter(adapter); var size = measureContent(adapter); + if (headerView != null) { + // Rendered above the list; grow the popup to fit it. + int spec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + headerView.measure(spec, spec); + size.x = Math.max(size.x, headerView.getMeasuredWidth()); + size.y += headerView.getMeasuredHeight(); + popup.setPromptPosition(ListPopupWindow.POSITION_PROMPT_ABOVE); + popup.setPromptView(headerView); + } popup.setContentWidth(size.x); popup.setHeight(size.y); popup.setBackgroundDrawable(createMaterial3Background()); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/MenuDialogBuilder.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/MenuDialogBuilder.java index f7abdbf2..f2e6dc21 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/MenuDialogBuilder.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/MenuDialogBuilder.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.ui; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/NotificationPermission.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/NotificationPermission.java index 1d7d4878..64ecbb2e 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/NotificationPermission.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/NotificationPermission.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.ui; import android.Manifest; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/RecordAudioPermission.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/RecordAudioPermission.java new file mode 100644 index 00000000..d7f554f6 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/RecordAudioPermission.java @@ -0,0 +1,75 @@ +// 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.ui; + +import android.Manifest; +import android.content.pm.PackageManager; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import cn.classfun.droidvm.R; + +/** + * Asks for RECORD_AUDIO when a VM is given a microphone peripheral. + * + *

    Whether this is what actually unlocks the mic is not certain: crosvm is forked by the root + * daemon, so its capture streams reach AudioFlinger as uid 0, which is checked separately from + * (and more leniently than) an app's runtime grant -- RECORD_AUDIO is an appops/uid permission, + * not one of the few that map to a supplementary GID, so it cannot be handed to a child process + * by putting it in a group. We ask anyway: it costs one dialog, it is what a user-visible + * "this VM listens to your mic" ought to look like, and it is the prerequisite for the fallback + * of proxying capture through an unprivileged app-side helper.

    + * + *

    Construct from an activity's {@code onCreate}: the result launcher has to be registered + * before the activity reaches STARTED. The pending action runs either way -- a denied permission + * is not a reason to refuse to save the config.

    + */ +public final class RecordAudioPermission { + private final AppCompatActivity activity; + private final ActivityResultLauncher launcher; + private Runnable pending; + + public RecordAudioPermission(@NonNull AppCompatActivity activity) { + this.activity = activity; + this.launcher = activity.registerForActivityResult( + new ActivityResultContracts.RequestPermission(), granted -> runPending()); + } + + /** + * Runs {@code action} immediately when the mic is already allowed; otherwise shows a + * rationale, requests the permission, and runs it once the user has responded. + */ + public void ensureThen(@NonNull Runnable action) { + if (granted()) { + action.run(); + return; + } + pending = action; + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.record_audio_permission_title) + .setMessage(R.string.record_audio_permission_message) + .setPositiveButton(R.string.record_audio_permission_allow, + (d, w) -> launcher.launch(Manifest.permission.RECORD_AUDIO)) + .setNegativeButton(R.string.record_audio_permission_skip, (d, w) -> runPending()) + .setOnCancelListener(d -> runPending()) + .show(); + } + + private void runPending() { + var action = pending; + pending = null; + if (action != null) action.run(); + } + + public boolean granted() { + return ContextCompat.checkSelfPermission(activity, + Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/SimpleAdapterDataObserver.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/SimpleAdapterDataObserver.java index 314e58b8..e7c73a18 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/SimpleAdapterDataObserver.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/SimpleAdapterDataObserver.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.ui; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/SimpleTextWatcher.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/SimpleTextWatcher.java index 2fcaae35..6bb3b4e6 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/SimpleTextWatcher.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/SimpleTextWatcher.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.ui; import android.text.Editable; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/SwipeableTabActivity.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/SwipeableTabActivity.java index 2cd6710d..dc404204 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/SwipeableTabActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/SwipeableTabActivity.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.ui; import android.view.MotionEvent; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/TabSwipeHelper.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/TabSwipeHelper.java index ff415d81..87869903 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/TabSwipeHelper.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/TabSwipeHelper.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.ui; import android.app.Activity; @@ -5,6 +8,7 @@ import android.view.VelocityTracker; import android.view.View; import android.view.ViewConfiguration; +import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -41,6 +45,7 @@ public interface TouchDispatcher { private float touchStartX, touchStartY; private boolean isDragging = false; private boolean touchDecided = false; + private boolean gestureDeclined = false; private boolean settling = false; private View dragPrevView, dragCurrentView, dragNextView; private Runnable animationEndCallback; @@ -63,6 +68,8 @@ private boolean onMotionEventDown( touchStartY = ev.getY(); isDragging = false; touchDecided = false; + gestureDeclined = touchesHorizontalScroller( + activity.getWindow().getDecorView(), ev.getRawX(), ev.getRawY()); if (velocityTracker == null) velocityTracker = VelocityTracker.obtain(); else velocityTracker.clear(); velocityTracker.addMovement(ev); @@ -73,6 +80,7 @@ private boolean onMotionEventMove( @NonNull MotionEvent ev, @NonNull TouchDispatcher superDispatch ) { + if (gestureDeclined) return false; if (velocityTracker != null) velocityTracker.addMovement(ev); if (!touchDecided) { @@ -159,6 +167,25 @@ public boolean onDispatchTouchEvent( return false; } + /** + * True if the point is over a view that scrolls horizontally on its own, e.g. an + * overflowing tab bar. Those gestures belong to that view, not to tab switching. + */ + private static boolean touchesHorizontalScroller(@NonNull View v, float rawX, float rawY) { + if (v.getVisibility() != View.VISIBLE) return false; + int[] loc = new int[2]; + v.getLocationOnScreen(loc); + if (rawX < loc[0] || rawX >= loc[0] + v.getWidth() || + rawY < loc[1] || rawY >= loc[1] + v.getHeight()) return false; + if (v.canScrollHorizontally(1) || v.canScrollHorizontally(-1)) return true; + if (v instanceof ViewGroup) { + var group = (ViewGroup) v; + for (int i = 0; i < group.getChildCount(); i++) + if (touchesHorizontalScroller(group.getChildAt(i), rawX, rawY)) return true; + } + return false; + } + private int getWidth() { return activity.getWindow().getDecorView().getWidth(); } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/UIContext.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/UIContext.java index b7cd64bf..f070de28 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/UIContext.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/UIContext.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.ui; import android.app.Activity; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/SimpleTerminalSessionClient.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/SimpleTerminalSessionClient.java index 07e471c3..1c612c02 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/SimpleTerminalSessionClient.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/SimpleTerminalSessionClient.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.ui.termux; import android.content.ClipboardManager; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/SimpleTerminalViewClient.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/SimpleTerminalViewClient.java index 0bcbf66a..9e6d101f 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/SimpleTerminalViewClient.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/SimpleTerminalViewClient.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.ui.termux; import android.util.Log; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/TerminalFonts.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/TerminalFonts.java index d6b64d9e..3ec7c015 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/TerminalFonts.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/TerminalFonts.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.ui.termux; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/TerminalPanelView.java b/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/TerminalPanelView.java new file mode 100644 index 00000000..69d3dc95 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/ui/termux/TerminalPanelView.java @@ -0,0 +1,244 @@ +// 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.ui.termux; + +import static android.content.Context.MODE_PRIVATE; +import static android.view.HapticFeedbackConstants.KEYBOARD_TAP; +import static android.view.KeyEvent.ACTION_DOWN; +import static android.view.KeyEvent.ACTION_UP; +import static android.view.KeyEvent.KEYCODE_DPAD_DOWN; +import static android.view.KeyEvent.KEYCODE_DPAD_LEFT; +import static android.view.KeyEvent.KEYCODE_DPAD_RIGHT; +import static android.view.KeyEvent.KEYCODE_DPAD_UP; +import static android.view.KeyEvent.KEYCODE_ESCAPE; +import static android.view.KeyEvent.KEYCODE_MOVE_END; +import static android.view.KeyEvent.KEYCODE_MOVE_HOME; +import static android.view.KeyEvent.KEYCODE_PAGE_DOWN; +import static android.view.KeyEvent.KEYCODE_PAGE_UP; +import static android.view.KeyEvent.KEYCODE_TAB; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; + +import android.content.Context; +import android.util.AttributeSet; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.termux.terminal.TerminalSession; +import com.termux.view.TerminalView; +import com.termux.view.TerminalViewClient; + +import cn.classfun.droidvm.R; + +/** Shared terminal presentation: font, zoom, soft keyboard and extra keys. */ +public final class TerminalPanelView extends LinearLayout { + private static final String PREFS_NAME = "droidvm_prefs"; + private static final String KEY_FONT_SIZE = "console_font_size"; + private static final float MIN_FONT_SIZE = 2; + private static final float MAX_FONT_SIZE = 48; + private static final float DEFAULT_FONT_SIZE = 5; + + private final TerminalView terminalView; + private final View extraKeysRow1; + private final View extraKeysRow2; + private TerminalSession terminalSession; + private float currentFontSize; + private boolean interactive = false; + private boolean ctrlDown = false; + private boolean altDown = false; + + private final TerminalViewClient viewClient = new SimpleTerminalViewClient() { + @Override + public float onScale(float scale) { + var dampened = 1.0f + (scale - 1.0f) * 0.1f; + currentFontSize = clampFontSize(currentFontSize * dampened); + applyFontSize(); + return dampened; + } + + @Override + public void onSingleTapUp(MotionEvent e) { + if (!interactive) return; + var imm = getContext().getSystemService(InputMethodManager.class); + if (imm == null) return; + terminalView.requestFocus(); + imm.showSoftInput(terminalView, 0); + } + + @Override + public boolean readControlKey() { + if (!ctrlDown) return false; + ctrlDown = false; + updateToggleButtons(); + return true; + } + + @Override + public boolean readAltKey() { + if (!altDown) return false; + altDown = false; + updateToggleButtons(); + return true; + } + }; + + public TerminalPanelView(@NonNull Context context) { + this(context, null); + } + + public TerminalPanelView(@NonNull Context context, @Nullable AttributeSet attrs) { + this(context, attrs, 0); + } + + public TerminalPanelView( + @NonNull Context context, + @Nullable AttributeSet attrs, + int defStyleAttr + ) { + super(context, attrs, defStyleAttr); + setOrientation(VERTICAL); + setBackgroundColor(context.getColor(android.R.color.black)); + LayoutInflater.from(context).inflate(R.layout.view_terminal_panel, this, true); + terminalView = findViewById(R.id.terminal_view); + extraKeysRow1 = findViewById(R.id.extra_keys_row1); + extraKeysRow2 = findViewById(R.id.extra_keys_row2); + terminalView.setTerminalViewClient(viewClient); + currentFontSize = loadFontSize(); + applyFontSize(); + TerminalFonts.apply(terminalView); + setupExtraKeys(); + applyInteractionState(); + } + + /** Attaches a session owned and stopped by the host activity. */ + public void attachSession(@NonNull TerminalSession session) { + terminalSession = session; + terminalView.attachSession(session); + } + + /** Drops the input reference when the host stops the attached session. */ + public void clearSession(@Nullable TerminalSession session) { + if (terminalSession == session) terminalSession = null; + } + + /** Allows input and reveals the terminal extra-key rows. Zoom always remains available. */ + public void setInteractive(boolean value) { + if (interactive == value) return; + interactive = value; + if (!interactive) { + ctrlDown = false; + altDown = false; + } + applyInteractionState(); + } + + public void refresh() { + terminalView.onScreenUpdated(); + } + + private void applyInteractionState() { + int visibility = interactive ? VISIBLE : GONE; + extraKeysRow1.setVisibility(visibility); + extraKeysRow2.setVisibility(visibility); + terminalView.setFocusable(interactive); + terminalView.setFocusableInTouchMode(interactive); + if (interactive) terminalView.requestFocus(); + updateToggleButtons(); + } + + private float loadFontSize() { + var saved = getContext().getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .getFloat(KEY_FONT_SIZE, DEFAULT_FONT_SIZE); + return clampFontSize(saved); + } + + private static float clampFontSize(float value) { + return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, value)); + } + + private void applyFontSize() { + var density = getResources().getDisplayMetrics().density; + terminalView.setTextSize((int) (currentFontSize * density)); + } + + private void saveFontSize() { + getContext().getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .edit() + .putFloat(KEY_FONT_SIZE, currentFontSize) + .apply(); + } + + @Override + protected void onDetachedFromWindow() { + saveFontSize(); + super.onDetachedFromWindow(); + } + + private void sendKey(int keyCode) { + if (!interactive || terminalSession == null) return; + var down = new KeyEvent(ACTION_DOWN, keyCode); + var up = new KeyEvent(ACTION_UP, keyCode); + terminalView.onKeyDown(keyCode, down); + terminalView.onKeyUp(keyCode, up); + } + + private void sendChar(char ch) { + if (interactive && terminalSession != null) + terminalSession.write(String.valueOf(ch)); + } + + private void updateToggleButtons() { + setToggleStyle(findViewById(R.id.btn_ctrl), ctrlDown); + setToggleStyle(findViewById(R.id.btn_alt), altDown); + } + + private void setToggleStyle(Button button, boolean active) { + if (active) { + button.setBackgroundColor(getContext().getColor(R.color.extra_key_bg_active)); + button.setTextColor(getContext().getColor(R.color.extra_key_text_active)); + } else { + button.setBackground(null); + button.setTextColor(getContext().getColor(R.color.extra_key_text)); + } + } + + private void setupExtraKeys() { + setExtraKeyClick(R.id.btn_esc, v -> sendKey(KEYCODE_ESCAPE)); + setExtraKeyClick(R.id.btn_slash, v -> sendChar('/')); + setExtraKeyClick(R.id.btn_dash, v -> sendChar('-')); + setExtraKeyClick(R.id.btn_home, v -> sendKey(KEYCODE_MOVE_HOME)); + setExtraKeyClick(R.id.btn_up, v -> sendKey(KEYCODE_DPAD_UP)); + setExtraKeyClick(R.id.btn_end, v -> sendKey(KEYCODE_MOVE_END)); + setExtraKeyClick(R.id.btn_pgup, v -> sendKey(KEYCODE_PAGE_UP)); + setExtraKeyClick(R.id.btn_tab, v -> sendKey(KEYCODE_TAB)); + setExtraKeyClick(R.id.btn_ctrl, v -> { + ctrlDown = !ctrlDown; + updateToggleButtons(); + }); + setExtraKeyClick(R.id.btn_alt, v -> { + altDown = !altDown; + updateToggleButtons(); + }); + setExtraKeyClick(R.id.btn_left, v -> sendKey(KEYCODE_DPAD_LEFT)); + setExtraKeyClick(R.id.btn_down, v -> sendKey(KEYCODE_DPAD_DOWN)); + setExtraKeyClick(R.id.btn_right, v -> sendKey(KEYCODE_DPAD_RIGHT)); + setExtraKeyClick(R.id.btn_pgdn, v -> sendKey(KEYCODE_PAGE_DOWN)); + } + + private void setExtraKeyClick(int id, OnClickListener listener) { + findViewById(id).setOnClickListener(v -> { + if (!interactive) return; + v.performHapticFeedback(KEYBOARD_TAP); + listener.onClick(v); + }); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/AssetUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/AssetUtils.java index 6ca1f30f..e1604e0a 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/AssetUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/AssetUtils.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.utils; import static android.os.Build.SUPPORTED_ABIS; @@ -292,8 +295,28 @@ public static void cleanupPrebuilt(@NonNull Context context) { } } + /** + * Serialises extraction. This writes into the data dir, so two callers at once are two + * writers interleaving mkdir and file creation on the same tree -- observed as one thread + * failing on "Failed to mkdir .../usr/bin" while the other completed, two milliseconds + * apart. The up-to-date check inside the lock is what makes the second caller cheap: by the + * time it gets in, the work is done and it returns immediately. + * + *

    Held here rather than at each call site because "only one extraction at a time" is a + * property of the operation, not something every caller should have to remember. + */ + private static final Object EXTRACT_LOCK = new Object(); + public static void extractPrebuilt( @NonNull Context context + ) throws IOException, JSONException { + synchronized (EXTRACT_LOCK) { + extractPrebuiltLocked(context); + } + } + + private static void extractPrebuiltLocked( + @NonNull Context context ) throws IOException, JSONException { if (!needsExtractPrebuilt(context)) { Log.d(TAG, "Prebuilt archive is up to date, skipping extraction"); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/BinaryUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/BinaryUtils.java index fb1039c6..2c186965 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/BinaryUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/BinaryUtils.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.utils; import static java.nio.charset.StandardCharsets.UTF_8; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java index 571414a4..026dc495 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/CpuUtils.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.utils; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -33,12 +36,20 @@ public static final class CpuCore { public final long maxFreqKHz; // 0 when unknown public final int tier; // 0 = lowest-freq cluster, ascending public final boolean big; // true when not in the lowest-freq cluster + /** + * Scheduler capacity on the kernel's 1024-per-biggest-core scale, as + * crosvm's {@code --cpu-capacity} wants it. Read from sysfs when the + * kernel exports it, otherwise derived from the frequency ratio; 0 only + * when neither is available. + */ + public final long capacity; - CpuCore(int index, long maxFreqKHz, int tier, boolean big) { + CpuCore(int index, long maxFreqKHz, int tier, boolean big, long capacity) { this.index = index; this.maxFreqKHz = maxFreqKHz; this.tier = tier; this.big = big; + this.capacity = capacity; } } @@ -51,21 +62,34 @@ public static final class CpuCore { public static List getCores() { var indices = listCoreIndices(); var freqs = new long[indices.size()]; + var caps = new long[indices.size()]; var distinct = new TreeSet(); + long maxFreq = 0; for (int i = 0; i < indices.size(); i++) { freqs[i] = readMaxFreqKHz(indices.get(i)); + caps[i] = readCapacity(indices.get(i)); if (freqs[i] > 0) distinct.add(freqs[i]); + maxFreq = Math.max(maxFreq, freqs[i]); } // Ascending tier index per distinct frequency; unknown (0) stays tier 0. var tierOf = new ArrayList<>(distinct); var cores = new ArrayList(indices.size()); for (int i = 0; i < indices.size(); i++) { int tier = freqs[i] > 0 ? tierOf.indexOf(freqs[i]) : 0; - cores.add(new CpuCore(indices.get(i), freqs[i], tier, tier > 0)); + // No cpu_capacity in sysfs (common outside big.LITTLE-aware kernels): + // fall back to the frequency ratio against the fastest core, which is + // what the arm64 kernel itself does when the DT omits capacities. + long cap = caps[i]; + if (cap <= 0 && freqs[i] > 0 && maxFreq > 0) + cap = Math.max(1, Math.round(MAX_CAPACITY * (double) freqs[i] / maxFreq)); + cores.add(new CpuCore(indices.get(i), freqs[i], tier, tier > 0, cap)); } return cores; } + /** Capacity of the biggest core on the kernel's scale. */ + public static final long MAX_CAPACITY = 1024; + /** Number of distinct frequency clusters (1 when frequencies are unknown). */ public static int tierCount(@NonNull List cores) { int max = 0; @@ -148,19 +172,38 @@ public static String coresCsvToHexMask(@NonNull String csv) { return mask.signum() == 0 ? "" : mask.toString(16); } + /** + * Parse a crosvm CPUSET spec -- a comma-separated list of indices and + * {@code low-high} ranges, e.g. {@code "0,1-3,5"} -- into ascending unique + * indices. Unparsable or reversed parts are skipped rather than throwing, + * matching the best-effort style of the rest of this class; callers that + * need to reject bad input compare the result against the input instead. + */ @NonNull - private static List parseCsv(@NonNull String csv) { - var out = new ArrayList(); - if (csv.isEmpty()) return out; - for (var part : csv.split(",")) { + public static List parseCpuSet(@NonNull String spec) { + var set = new TreeSet(); + for (var part : spec.split(",")) { part = part.trim(); if (part.isEmpty()) continue; + int dash = part.indexOf('-', 1); try { - out.add(Integer.parseInt(part)); + if (dash < 0) { + set.add(Integer.parseInt(part)); + continue; + } + int lo = Integer.parseInt(part.substring(0, dash).trim()); + int hi = Integer.parseInt(part.substring(dash + 1).trim()); + if (lo > hi) continue; + for (int i = lo; i <= hi; i++) set.add(i); } catch (NumberFormatException ignored) { } } - return out; + return new ArrayList<>(set); + } + + @NonNull + private static List parseCsv(@NonNull String csv) { + return parseCpuSet(csv); } @NonNull @@ -187,16 +230,20 @@ private static List listCoreIndices() { return out; } + private static long readCapacity(int index) { + return tryReadLong(fmt("%s/cpu%d/cpu_capacity", CPU_ROOT, index)); + } + private static long readMaxFreqKHz(int index) { // cpuinfo_max_freq is the hardware ceiling; scaling_max_freq is the // policy ceiling (usually equal). Try the direct read first, then a // root-backed read, before giving up on this core. - long v = tryReadFreq(fmt("%s/cpu%d/cpufreq/cpuinfo_max_freq", CPU_ROOT, index)); + long v = tryReadLong(fmt("%s/cpu%d/cpufreq/cpuinfo_max_freq", CPU_ROOT, index)); if (v > 0) return v; - return tryReadFreq(fmt("%s/cpu%d/cpufreq/scaling_max_freq", CPU_ROOT, index)); + return tryReadLong(fmt("%s/cpu%d/cpufreq/scaling_max_freq", CPU_ROOT, index)); } - private static long tryReadFreq(@NonNull String path) { + private static long tryReadLong(@NonNull String path) { String raw = null; try { raw = FileUtils.readFile(path); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/FileUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/FileUtils.java index 6f9075f4..648e0ec6 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/FileUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/FileUtils.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.utils; import static cn.classfun.droidvm.lib.utils.RunUtils.escapedString; @@ -327,6 +330,20 @@ public static String externalPath() { return Environment.getExternalStorageDirectory().getPath(); } + /** + * {@code path} with {@code //}, {@code ..} and symlinks resolved, so two spellings of the + * same file compare equal; the original string when the filesystem will not say (an + * unreadable parent directory, say), which is no worse than not having asked. + */ + @NonNull + public static String canonicalPath(@NonNull String path) { + try { + return new File(path).getCanonicalPath(); + } catch (Exception e) { + return path; + } + } + public static boolean deleteFile(@NonNull String path) { try { return new File(path).delete(); diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java index ba4d3042..8ccd0c5c 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/ImageUtils.java @@ -1,14 +1,27 @@ +// 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.utils; import static cn.classfun.droidvm.lib.utils.AssetUtils.getPrebuiltBinaryPath; +import static cn.classfun.droidvm.lib.utils.FileUtils.canonicalPath; import static cn.classfun.droidvm.lib.utils.RunUtils.runListQuiet; +import static cn.classfun.droidvm.lib.utils.StringUtils.basename; +import static cn.classfun.droidvm.lib.utils.StringUtils.dirname; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.json.JSONException; import org.json.JSONObject; +import java.io.File; +import java.io.IOException; + +import cn.classfun.droidvm.ui.disk.create.DiskFormat; + public final class ImageUtils { private ImageUtils() { } @@ -61,4 +74,109 @@ public static boolean hasCompressedClusters(String path) { return false; } } + + /** Whether the image declares a backing file (i.e. it is an overlay). Failures read false. */ + public static boolean hasBackingFile(String path) { + try { + return !getImageInfo(path).optString("backing-filename", "").isEmpty(); + } catch (Exception e) { + return false; + } + } + + /** + * The absolute, canonical path of {@code path}'s backing image, or null when it has none. + * Relative headers are resolved against the overlay's own directory, the way qemu does it. + * + *

    Unlike {@link #hasBackingFile}, this refuses to guess: a caller that has to reproduce + * the whole chain (packaging, above all) cannot treat "cannot tell" as "no parent", because + * the file it would then leave behind is the one the guest needs. So an unreadable qcow2 + * throws - qemu-img failing on one usually means its backing file is already gone - and so + * does a header naming a file that is not there. Any other format has no chain to lose: + * qemu-img not reading it says nothing, and refusing it would break exports that work. + */ + @Nullable + public static String backingOf(@NonNull String path) throws IOException { + JSONObject info; + try { + info = getImageInfo(path); + } catch (Exception e) { + if (DiskFormat.fromFilename(path) != DiskFormat.QCOW2) return null; + throw new IOException(fmt( + "cannot read %s - its backing image may be missing", path + ), e); + } + var backing = info.optString("full-backing-filename", + info.optString("backing-filename", "")); + if (backing.isEmpty()) return null; + if (!backing.startsWith("/")) backing = pathJoin(dirname(path), backing); + backing = canonicalPath(backing); + if (!new File(backing).isFile()) + throw new IOException(fmt("missing backing image: %s", backing)); + return backing; + } + + /** + * Point {@code overlay}'s header at {@code backing} without touching a cluster + * ({@code qemu-img rebase -u}). Correct only when the two images already describe the same + * data and just the path changed - a copy of a whole chain landing in a new folder, which + * is what importing a package is. + */ + public static void rebaseBacking( + @NonNull String overlay, + @NonNull String backing + ) throws IOException { + String format; + try { + format = getImageInfo(backing).optString("format", "qcow2"); + } catch (Exception e) { + format = "qcow2"; + } + var result = runListQuiet( + getPrebuiltBinaryPath("qemu-img"), "rebase", + "-u", "-b", backing, "-F", format, overlay + ); + if (result.isSuccess()) return; + result.printLog("qemu-img"); + throw new IOException(fmt( + "qemu-img rebase failed for %s: %d", basename(overlay), result.getCode() + )); + } + + /** + * Whether the image carries qcow2 internal snapshots ({@code qemu-img snapshot -c}). crosvm + * refuses to open such an image for writing - it has no snapshot support, and writing would + * damage the snapshots rather than ignore them - so a VM disk must be flattened first. + * Detection failures return {@code false}: an image we can't read tells us nothing, and a + * real start would surface the problem anyway. + */ + public static boolean hasInternalSnapshots(String path) { + try { + var snapshots = getImageInfo(path).optJSONArray("snapshots"); + return snapshots != null && snapshots.length() > 0; + } catch (Exception e) { + return false; + } + } + + /** + * The image's effective compression as qemu names it: {@code "none"} unless the image + * actually stores compressed clusters (see {@link #hasCompressedClusters}); the qcow2 + * header's {@code compression-type} then picks {@code "zlib"} vs {@code "zstd"} (that header + * field alone can't - it reads "zlib" for every v3 image). Detection failures return + * {@code "none"}: an undetectable image is treated as uncompressed. + */ + @NonNull + public static String detectCompression(String path) { + try { + if (!hasCompressedClusters(path)) return "none"; + var info = getImageInfo(path); + var fmtSpecific = info.optJSONObject("format-specific"); + var data = fmtSpecific == null ? null : fmtSpecific.optJSONObject("data"); + var type = data == null ? "" : data.optString("compression-type", ""); + return "zstd".equals(type) ? "zstd" : "zlib"; + } catch (Exception e) { + return "none"; + } + } } diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/JsonUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/JsonUtils.java index 86d11638..58d9319b 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/JsonUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/JsonUtils.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.utils; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/NetUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/NetUtils.java index f4506352..31359401 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/NetUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/NetUtils.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.utils; import static org.yaml.snakeyaml.util.UriEncoder.encode; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/ProcessUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/ProcessUtils.java index 698e3da1..7e99eec0 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/ProcessUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/ProcessUtils.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.utils; import static java.lang.Integer.parseInt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/RunUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/RunUtils.java index 31b56dfa..c30f1476 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/RunUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/RunUtils.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.utils; import static java.util.Objects.requireNonNullElse; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/ShareUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/ShareUtils.java index ee824e39..1f94c1a5 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/ShareUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/ShareUtils.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.utils; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/StringUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/StringUtils.java index 649dbc10..b9b6c814 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/StringUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/StringUtils.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.utils; import static java.nio.charset.StandardCharsets.UTF_8; @@ -12,6 +15,7 @@ import android.net.Uri; import android.provider.DocumentsContract; import android.system.Os; +import android.text.InputFilter; import android.widget.EditText; import androidx.annotation.NonNull; @@ -22,6 +26,7 @@ import java.io.IOException; import java.io.InputStream; import java.security.SecureRandom; +import java.util.Collection; import java.util.Formatter; import java.util.Locale; @@ -165,6 +170,26 @@ public static String extensionLower(@NonNull String path) { return extension(path).toLowerCase(Locale.ROOT); } + /** + * {@code name} reduced to one path component that is safe to create on disk and to name an + * entry inside a package: path separators, control characters and the punctuation Windows + * reserves all become {@code _}. Falls back to {@code fallback} when nothing usable is left + * (an empty name, or one that would name a directory rather than a file in it), so callers + * never have to handle the degenerate case themselves. + */ + @NonNull + public static String safeFileName(@NonNull String name, @NonNull String fallback) { + var sb = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c < 0x20 || c == 0x7f || "\\/:*?\"<>|".indexOf(c) >= 0) c = '_'; + sb.append(c); + } + var out = sb.toString().trim(); + if (out.isEmpty() || out.equals(".") || out.equals("..")) return fallback; + return out; + } + @NonNull public static String pathJoin(@NonNull String base, @NonNull String child) { if (base.endsWith("/")) base = base.substring(0, base.length() - 1); @@ -201,6 +226,18 @@ public static String joinNonEmpty(@NonNull String sep, @NonNull String... parts) return sb.toString(); } + /** + * A {@code "\n- a\n- b"} bullet list, the shape the disk and VM dialogs paste + * into a message resource. The leading newline is part of it: the list always + * follows a sentence, and every caller was writing that newline by hand. + */ + @NonNull + public static String bulletList(@NonNull Collection items) { + var sb = new StringBuilder(); + for (var item : items) sb.append("\n- ").append(item); + return sb.toString(); + } + @NonNull public static String fmt(String fmt, Object... args) { return new Formatter(Locale.ROOT).format(fmt, args).toString(); @@ -223,6 +260,75 @@ public static String generateRandomPassword(int length) { return sb.toString(); } + /** + * The only symbols allowed in passwords that ride through the temp rescue + * VM's generated chpasswd script. Everything quoting- or expansion-related + * (' " \ $ `), whitespace, and shell/script metacharacters stay out, so a + * password cannot break the script even if an escaping layer regresses. + */ + public static final String SHELL_SAFE_PASSWORD_SYMBOLS = "!@#%^*+-=_.,:?"; + + public static final String GROUPED_PASSWORD_UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ"; + public static final String GROUPED_PASSWORD_LOWER = "abcdefghijkmnpqrstuvwxyz"; + public static final String GROUPED_PASSWORD_DIGITS = "23456789"; + public static final String GROUPED_PASSWORD_SYMBOLS = "!@#%*+-=?"; + + public static boolean isShellSafePasswordChar(char c) { + return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') + || SHELL_SAFE_PASSWORD_SYMBOLS.indexOf(c) >= 0; + } + + /** Gate for the rescue-VM password path: letters, digits and the safe symbols only. */ + public static boolean isShellSafePassword(@NonNull CharSequence password) { + for (int i = 0; i < password.length(); i++) + if (!isShellSafePasswordChar(password.charAt(i))) return false; + return true; + } + + /** Whitelist filter for password fields feeding {@link #isShellSafePassword}. */ + @NonNull + public static InputFilter shellSafePasswordFilter() { + return (source, start, end, dest, dstart, dend) -> { + boolean clean = true; + for (int i = start; i < end && clean; i++) + clean = isShellSafePasswordChar(source.charAt(i)); + if (clean) return null; // accept unchanged + var sb = new StringBuilder(end - start); + for (int i = start; i < end; i++) + if (isShellSafePasswordChar(source.charAt(i))) + sb.append(source.charAt(i)); + return sb.toString(); + }; + } + + /** + * Grouped as 2 uppercase + 3 lowercase + 4 digits + 2 symbols, so the value + * stays strong yet easy to read back and retype on a VM console. Glyphs that + * are ambiguous on screen (I/O vs l/o vs 0/1) are left out of every group, + * and each group draws only from the shell-safe whitelist above. + */ + @NonNull + public static String generateGroupedPassword() { + var random = new SecureRandom(); + var sb = new StringBuilder(11); + appendRandomChars(sb, random, GROUPED_PASSWORD_UPPER, 2); + appendRandomChars(sb, random, GROUPED_PASSWORD_LOWER, 3); + appendRandomChars(sb, random, GROUPED_PASSWORD_DIGITS, 4); + appendRandomChars(sb, random, GROUPED_PASSWORD_SYMBOLS, 2); + return sb.toString(); + } + + private static void appendRandomChars( + @NonNull StringBuilder sb, + @NonNull SecureRandom random, + @NonNull String charset, + int count + ) { + for (int i = 0; i < count; i++) + sb.append(charset.charAt(random.nextInt(charset.length()))); + } + @NonNull @SuppressWarnings("unused") public static String base64Encode(@NonNull byte[] data) { diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/ThreadUtils.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/ThreadUtils.java index 3c47dbe7..a77adbb6 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/ThreadUtils.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/ThreadUtils.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.utils; import static java.util.concurrent.Executors.newCachedThreadPool; diff --git a/app/src/main/java/cn/classfun/droidvm/lib/utils/Try.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/Try.java index ed195bbb..c6a03023 100644 --- a/app/src/main/java/cn/classfun/droidvm/lib/utils/Try.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/Try.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.utils; import androidx.annotation.Nullable; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/SplashActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/SplashActivity.java index efaf3736..9300c8ef 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/SplashActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/SplashActivity.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.ui; import static cn.classfun.droidvm.lib.utils.AssetUtils.extractBinaries; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/agent/AgentOperationActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/agent/AgentOperationActivity.java index 83f4b35b..278f532b 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/agent/AgentOperationActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/agent/AgentOperationActivity.java @@ -1,18 +1,25 @@ +// 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.ui.agent; import static android.view.View.GONE; import static android.view.View.VISIBLE; +import static cn.classfun.droidvm.lib.utils.AssetUtils.getAssetBinaryPath; import static cn.classfun.droidvm.lib.utils.FileUtils.findExecute; import static cn.classfun.droidvm.lib.utils.ProcessUtils.SIGHUP; import static cn.classfun.droidvm.lib.utils.ProcessUtils.shellKillProcess; +import static cn.classfun.droidvm.lib.utils.RunUtils.escapedString; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; +import static cn.classfun.droidvm.lib.utils.ThreadUtils.threadSleep; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.util.Base64; import android.util.Log; import android.widget.ImageView; import android.widget.ProgressBar; @@ -26,12 +33,16 @@ import com.google.android.material.button.MaterialButton; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.termux.terminal.TerminalSession; -import com.termux.view.TerminalView; import org.json.JSONObject; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; import cn.classfun.droidvm.DroidVMApp; import cn.classfun.droidvm.R; @@ -39,41 +50,83 @@ import cn.classfun.droidvm.lib.daemon.ForegroundCallback; import cn.classfun.droidvm.lib.store.disk.DiskStore; import cn.classfun.droidvm.lib.ui.termux.SimpleTerminalSessionClient; -import cn.classfun.droidvm.lib.ui.termux.TerminalFonts; -import cn.classfun.droidvm.lib.ui.termux.SimpleTerminalViewClient; +import cn.classfun.droidvm.lib.ui.termux.TerminalPanelView; +import cn.classfun.droidvm.ui.agent.base.AgentPayloadChunks; import cn.classfun.droidvm.ui.agent.base.AgentVM; import cn.classfun.droidvm.ui.agent.base.BaseAction; +import cn.classfun.droidvm.ui.agent.password.PasswordAction; +/** Runs maintenance actions through one visible rescue console. */ public final class AgentOperationActivity extends AppCompatActivity implements DaemonConnection.EventListener, ForegroundCallback { private static final String TAG = "AgentOperationActivity"; public static final String EXTRA_AGENT_VM_JSON = "agent_vm_json"; + public static final String EXTRA_AUTOFINISH_ON_SUCCESS = "autofinish_on_success"; + private static final String AGENT_MARKER = "__DROIDVM_AGENT__:"; + private static final String TTY_READY_MARKER = AGENT_MARKER + "TTY:READY"; // concat-ok: compile-time constant + private static final String READY_MARKER = AGENT_MARKER + "READY"; // concat-ok: compile-time constant + private static final String STAGE_READY_MARKER = AGENT_MARKER + "STAGE:READY"; // concat-ok: compile-time constant + private static final String STAGE_CHUNK_MARKER = AGENT_MARKER + "STAGE:CHUNK:"; // concat-ok: compile-time constant + private static final String SCRIPT_READY_MARKER = AGENT_MARKER + "SCRIPT:READY"; // concat-ok: compile-time constant + private static final String SHELL_READY_MARKER = AGENT_MARKER + "SHELL:READY"; // concat-ok: compile-time constant + private static final String RESULT_OK_MARKER = AGENT_MARKER + "RESULT:OK"; // concat-ok: compile-time constant + private static final String RESULT_ERROR_MARKER = AGENT_MARKER + "RESULT:ERROR:"; // concat-ok: compile-time constant + private static final String ACTION_START_MARKER = AGENT_MARKER + "ACTION:START:"; // concat-ok: compile-time constant + private static final String ACTION_OK_MARKER = AGENT_MARKER + "ACTION:OK:"; // concat-ok: compile-time constant + private static final String ACTION_ERROR_MARKER = AGENT_MARKER + "ACTION:ERROR:"; // concat-ok: compile-time constant + private static final String ACTION_SKIPPED_MARKER = AGENT_MARKER + "ACTION:SKIPPED:"; // concat-ok: compile-time constant + private static final String[] PASSWORD_PROMPTS = new String[]{ + "New password:", + "Re-enter new password:", + "Retype new password:", + "Enter new UNIX password:", + "Retype new UNIX password:", + }; + private static final int AGENT_BUFFER_LIMIT = 64 * 1024; + private static final int PASSWORD_PROMPT_TAIL_LIMIT = 128; + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final StringBuilder agentOutput = new StringBuilder(); + private final Map actionPasswords = new ConcurrentHashMap<>(); + private final AtomicBoolean cleanupStarted = new AtomicBoolean(false); private ProgressBar progressSpinner; private ImageView ivStatus; private TextView tvTitle; private TextView tvStatus; - private TerminalView terminalView; + private TerminalPanelView terminalPanel; private TerminalSession terminalSession; private MaterialButton btnCancel; private MaterialToolbar toolbar; - private boolean finished = false; + private volatile boolean ttyShellRequested = false; + private volatile boolean bootstrapSent = false; + private volatile boolean payloadStageStarted = false; + private volatile boolean payloadDecodeSent = false; + private volatile boolean actionSent = false; + private volatile boolean resultShown = false; + private volatile boolean handoffRequested = false; + private volatile boolean handoffComplete = false; + private volatile boolean actionSkipped = false; + private volatile boolean closing = false; + private volatile boolean activityDone = false; + private volatile boolean vmExited = false; + private boolean autoFinishOnSuccess = false; private String vmId = null; private AgentVM agentVM = null; - private BaseAction action = null; + private volatile List actionPayloadChunks = Collections.emptyList(); + private int nextPayloadChunk = 0; + private String activePassword = null; + private String passwordPromptTail = ""; + private int activeActionIndex = -1; private final SimpleTerminalSessionClient sessionClient = new SimpleTerminalSessionClient(this) { @Override public void onTextChanged(@NonNull TerminalSession s) { mainHandler.post(() -> { - if (terminalView != null) terminalView.onScreenUpdated(); + if (terminalPanel != null) terminalPanel.refresh(); }); } }; - private final SimpleTerminalViewClient viewClient = new SimpleTerminalViewClient() { - }; - @NonNull public static Intent createIntent( @NonNull Context context, @@ -97,7 +150,8 @@ protected void onCreate(Bundle savedInstanceState) { ivStatus = findViewById(R.id.iv_status); tvTitle = findViewById(R.id.tv_title); tvStatus = findViewById(R.id.tv_status); - terminalView = findViewById(R.id.terminal_view); + terminalPanel = findViewById(R.id.terminal_panel); + terminalPanel.setInteractive(false); btnCancel = findViewById(R.id.btn_cancel); btnCancel.setOnClickListener(v -> confirmCancel()); initialize(); @@ -113,6 +167,7 @@ public void handleOnBackPressed() { } }); var intent = getIntent(); + autoFinishOnSuccess = intent.getBooleanExtra(EXTRA_AUTOFINISH_ON_SUCCESS, false); var agentVmJson = intent.getStringExtra(EXTRA_AGENT_VM_JSON); if (agentVmJson == null) { Log.e(TAG, "Missing agent_vm_json extra"); @@ -123,58 +178,88 @@ public void handleOnBackPressed() { var diskStore = new DiskStore(); diskStore.load(this); agentVM = new AgentVM(diskStore, new JSONObject(agentVmJson)); - action = BaseAction.createAction(agentVM); + var actions = BaseAction.createActions(agentVM); + for (int i = 0; i < actions.size(); i++) + if (actions.get(i) instanceof PasswordAction) + actionPasswords.put(i, ((PasswordAction) actions.get(i)).getPassword()); + var script = BaseAction.buildRescueScript(actions); + for (var action : actions) action.clearSecrets(); + var actionPayload = Base64.encodeToString( + script.getBytes(StandardCharsets.UTF_8), Base64.NO_WRAP); + actionPayloadChunks = AgentPayloadChunks.split(actionPayload); + // Do not retain the JSON copy containing the password for the Activity lifetime. + intent.removeExtra(EXTRA_AGENT_VM_JSON); } catch (Exception e) { - Log.e(TAG, "Failed to parse AgentVM", e); + Log.e(TAG, "Failed to prepare AgentVM action", e); finish(); return; } - terminalView.setTerminalViewClient(viewClient); - initTerminal(); tvTitle.setText(R.string.agent_operation_title); tvStatus.setText(R.string.agent_operation_preparing); - appendLog(getString(R.string.agent_operation_log_preparing)); runOnPool(this::startAgent); } - private void initTerminal() { - var shell = findExecute("sh"); + /** Connects the embedded panel to the operation console without enabling user input yet. */ + private void startConsoleSession(@NonNull String stream) { + stopConsoleSession(); + if (vmId == null || vmId.isEmpty() || closing || vmExited) return; + var shell = findExecute("su", "/system/bin/su"); var cwd = getFilesDir().getAbsolutePath(); - var args = new String[]{"sh", "-c", "while true; do sleep 86400; done"}; + var command = fmt( + "exec %s console --raw %s %s", + escapedString(getAssetBinaryPath("droidvm")), + escapedString(vmId), + escapedString(stream) + ); + var args = new String[]{"su", "-c", command}; var env = new String[]{ "TERM=xterm-256color", "PATH=/system/bin", fmt("HOME=%s", cwd), }; terminalSession = new TerminalSession(shell, cwd, args, env, null, sessionClient); - float density = getResources().getDisplayMetrics().density; - terminalView.setTextSize((int) (4 * density)); - TerminalFonts.apply(terminalView); - terminalView.attachSession(terminalSession); + terminalPanel.attachSession(terminalSession); } - private void appendLog(@NonNull String text) { - if (terminalSession == null) return; - var emulator = terminalSession.getEmulator(); - if (emulator == null) return; - if (text.contains("\n") && !text.contains("\r")) - text = text.replace("\n", "\r\n"); - var bytes = text.getBytes(StandardCharsets.UTF_8); - emulator.append(bytes, bytes.length); - terminalView.onScreenUpdated(); + /** Waits for the explicitly configured console without blocking control or auto-finish. */ + private void startConsoleWhenReady() { + var stream = agentVM.getOperationConsoleStream(); + if (stream == null) return; + runOnPool(() -> { + for (int i = 0; i < 50 && !closing && !vmExited; i++) { + if (isOperationConsoleReadable(stream)) { + runOnUiThread(() -> { + if (!closing && !vmExited && terminalSession == null) + startConsoleSession(stream); + }); + return; + } + threadSleep(100); + } + if (!closing && !vmExited) + Log.w(TAG, "Operation console did not become readable"); + }); } - private void startAgent() { + private boolean isOperationConsoleReadable(@NonNull String stream) { try { - agentVM.prepareVars(); + var infoReq = new JSONObject(); + infoReq.put("command", "vm_console_info"); + infoReq.put("vm_id", vmId); + infoReq.put("stream", stream); + var infoResp = DaemonConnection.getInstance().request(infoReq); + var data = infoResp.optJSONObject("data"); + return infoResp.optBoolean("success", false) + && data != null && data.optBoolean("readable", false); } catch (Exception e) { - Log.e(TAG, "Failed to prepare agent", e); - runOnUiThread(() -> showFailed(getString(R.string.agent_operation_prepare_failed))); - return; + if (!closing && !vmExited) Log.d(TAG, "Operation console is not ready", e); } + return false; + } + + private void startAgent() { runOnUiThread(() -> { tvStatus.setText(R.string.agent_operation_creating_vm); - appendLog(getString(R.string.agent_operation_log_creating_vm)); }); var vmConfig = agentVM.buildVM(); var conn = DaemonConnection.getInstance(); @@ -192,7 +277,6 @@ private void startAgent() { if (vmId.isEmpty()) throw new RuntimeException("vm_create returned empty vm_id"); runOnUiThread(() -> { tvStatus.setText(R.string.agent_operation_starting_vm); - appendLog(getString(R.string.agent_operation_log_starting_vm)); }); var startReq = new JSONObject(); startReq.put("command", "vm_start"); @@ -202,16 +286,12 @@ private void startAgent() { var msg = startResp.optString("message", "unknown error"); throw new RuntimeException(fmt("vm_start failed: %s", msg)); } - runOnUiThread(() -> { - tvStatus.setText(R.string.agent_operation_running); - appendLog(getString(R.string.agent_operation_log_running)); - }); + runOnUiThread(() -> tvStatus.setText(R.string.agent_operation_running)); + startConsoleWhenReady(); } catch (Exception e) { Log.e(TAG, "Failed to create/start agent VM", e); runOnUiThread(() -> showFailed( - getString(R.string.agent_operation_start_failed, e.getMessage()) - )); - cleanupVM(); + getString(R.string.agent_operation_start_failed, e.getMessage()), false)); } } @@ -237,165 +317,480 @@ public void onDaemonEvent(@NonNull JSONObject msg) { if (!eventVmId.equals(vmId)) return; var event = data.optString("event", ""); if (event.equals("output")) { - var text = URLDecoder.decode(data.optString("data", ""), StandardCharsets.UTF_8); var stream = data.optString("stream", ""); - if (!text.isEmpty() && (stream.equals("stdio") || stream.equals("uart"))) - mainHandler.post(() -> appendLog(text)); + var operationStream = agentVM.getOperationConsoleStream(); + if (operationStream == null || !stream.equals(operationStream)) return; + var text = URLDecoder.decode(data.optString("data", ""), StandardCharsets.UTF_8); + if (text.isEmpty()) return; + handleAgentOutput(text); } else if (event.equals("exited")) { int exitCode = data.optInt("exit_code", -1); mainHandler.post(() -> onVMFinished(exitCode)); } } - @Override - public void onDaemonConnected() { + private void handleAgentOutput(@NonNull String text) { + String snapshot; + synchronized (agentOutput) { + agentOutput.append(text); + if (agentOutput.length() > AGENT_BUFFER_LIMIT) + agentOutput.delete(0, agentOutput.length() - AGENT_BUFFER_LIMIT); + snapshot = agentOutput.toString(); + } + updateActivePassword(snapshot); + handlePasswordPrompts(text); + if (snapshot.contains(ACTION_SKIPPED_MARKER)) + actionSkipped = true; + if (!ttyShellRequested && (snapshot.contains("~ #") + || snapshot.contains("Run /bin/sh as init process"))) { + ttyShellRequested = true; + sendOperationCommand( + "busybox setsid -c sh -c 'printf \"\\n__DROIDVM_%s:TTY:READY\\n\" " + + "AGENT__; exec sh'", + true); + } + if (ttyShellRequested && !bootstrapSent && snapshot.contains(TTY_READY_MARKER)) { + bootstrapSent = true; + sendOperationCommand("stty -echo 2>/dev/null; " + + "mount -t proc proc /proc 2>/dev/null || true; " + + "mount -t sysfs sysfs /sys 2>/dev/null || true; " + + "mount -t devtmpfs devtmpfs /dev 2>/dev/null || true; " + + "busybox mdev -s; " + + "printf '\\n__DROIDVM_%s:READY\\n' AGENT__", true); + } + if (bootstrapSent && !payloadStageStarted && snapshot.contains(READY_MARKER)) { + payloadStageStarted = true; + nextPayloadChunk = 0; + if (actionPayloadChunks.isEmpty()) { + mainHandler.post(() -> showFailed( + getString(R.string.agent_operation_prepare_failed), true)); + return; + } + sendOperationCommand( + "rm -f /run/droidvm-rescue.b64 /run/droidvm-rescue.sh; " + + "if : > /run/droidvm-rescue.b64; then " + + "printf '\\n__DROIDVM_%s:STAGE:READY\\n' AGENT__; " + + "else stty echo; " + + "printf '\\n__DROIDVM_%s:RESULT:ERROR:SCRIPT_FAILED\\n' AGENT__; fi", + true); + } + continuePayloadStage(snapshot); + if (payloadDecodeSent && !actionSent && snapshot.contains(SCRIPT_READY_MARKER)) { + actionSent = true; + sendOperationCommand( + "busybox sh /run/droidvm-rescue.sh; rc=$?; " + + "rm -f /run/droidvm-rescue.sh; " + + "[ $rc -eq 0 ] || " + + "printf '\\n__DROIDVM_%s:RESULT:ERROR:SCRIPT_FAILED\\n' AGENT__", + true); + } + if (handoffRequested && !handoffComplete && snapshot.contains(SHELL_READY_MARKER)) { + handoffComplete = true; + mainHandler.post(() -> { + if (!closing && !vmExited) terminalPanel.setInteractive(true); + }); + } + if (!resultShown && snapshot.contains(RESULT_OK_MARKER)) { + clearPasswords(); + mainHandler.post(this::showSuccess); + return; + } + if (!resultShown && snapshot.contains(RESULT_ERROR_MARKER)) { + clearPasswords(); + var start = snapshot.lastIndexOf(RESULT_ERROR_MARKER) + RESULT_ERROR_MARKER.length(); + var end = snapshot.indexOf('\n', start); + if (end < 0) end = snapshot.length(); + var code = snapshot.substring(start, end).replace("\r", "").trim(); + mainHandler.post(() -> showFailed(describeAgentError(code), true)); + } } - @Override - public void onDaemonDisconnected() { - if (!finished) { - mainHandler.post(() -> showFailed(getString(R.string.agent_operation_daemon_disconnected))); + private void continuePayloadStage(@NonNull String snapshot) { + if (!payloadStageStarted || payloadDecodeSent) return; + if (nextPayloadChunk == 0) { + if (!snapshot.contains(STAGE_READY_MARKER)) return; + } else { + var expectedMarker = fmt( + "%s%d:OK", STAGE_CHUNK_MARKER, nextPayloadChunk - 1); + if (!snapshot.contains(expectedMarker)) return; } + var chunks = actionPayloadChunks; + if (nextPayloadChunk < chunks.size()) { + int index = nextPayloadChunk; + nextPayloadChunk++; + var command = fmt( + "if printf '%%s' '%s' >> /run/droidvm-rescue.b64; then " + + "printf '\\n__DROIDVM_%%s:STAGE:CHUNK:%d:OK\\n' AGENT__; " + + "else stty echo; " + + "printf '\\n__DROIDVM_%%s:RESULT:ERROR:SCRIPT_FAILED\\n' AGENT__; fi", + chunks.get(index), index); + sendOperationCommand(command, true); + return; + } + payloadDecodeSent = true; + actionPayloadChunks = Collections.emptyList(); + sendOperationCommand( + "if busybox base64 -d < /run/droidvm-rescue.b64 " + + "> /run/droidvm-rescue.sh " + + "&& busybox sh -n /run/droidvm-rescue.sh; then " + + "rm -f /run/droidvm-rescue.b64; stty echo; " + + "printf '\\n__DROIDVM_%s:SCRIPT:READY\\n' AGENT__; " + + "else rm -f /run/droidvm-rescue.b64 /run/droidvm-rescue.sh; " + + "stty echo; " + + "printf '\\n__DROIDVM_%s:RESULT:ERROR:SCRIPT_FAILED\\n' AGENT__; fi", + true); } - private void onVMFinished(int exitCode) { - if (finished) return; - finished = true; - appendLog(fmt( - "\n--- %s (exit code: %d) ---\n", - getString(R.string.agent_operation_vm_exited), exitCode - )); + private void updateActivePassword(@NonNull String snapshot) { + int start = snapshot.lastIndexOf(ACTION_START_MARKER); + int finished = Math.max(snapshot.lastIndexOf(ACTION_OK_MARKER), + Math.max(snapshot.lastIndexOf(ACTION_ERROR_MARKER), + snapshot.lastIndexOf(ACTION_SKIPPED_MARKER))); + if (finished > start) { + if (activeActionIndex >= 0) actionPasswords.remove(activeActionIndex); + activeActionIndex = -1; + activePassword = null; + passwordPromptTail = ""; + return; + } + if (start < 0) return; + int valueStart = start + ACTION_START_MARKER.length(); + int lineEnd = snapshot.indexOf('\n', valueStart); + if (lineEnd < 0) return; + var marker = snapshot.substring(valueStart, lineEnd).replace("\r", "").trim(); + int separator = marker.indexOf(':'); + if (separator <= 0) return; + try { + int index = Integer.parseInt(marker.substring(0, separator)); + if (index == activeActionIndex) return; + activeActionIndex = index; + activePassword = actionPasswords.get(index); + passwordPromptTail = ""; + } catch (NumberFormatException ignored) { + } + } + + private void handlePasswordPrompts(@NonNull String text) { + var password = activePassword; + if (password == null) return; + passwordPromptTail = passwordPromptTail + text; + while (true) { + int first = -1; + int promptLength = 0; + for (var prompt : PASSWORD_PROMPTS) { + int index = passwordPromptTail.indexOf(prompt); + if (index >= 0 && (first < 0 || index < first)) { + first = index; + promptLength = prompt.length(); + } + } + if (first < 0) { + if (passwordPromptTail.length() > PASSWORD_PROMPT_TAIL_LIMIT) + passwordPromptTail = passwordPromptTail.substring( + passwordPromptTail.length() - PASSWORD_PROMPT_TAIL_LIMIT); + return; + } + passwordPromptTail = passwordPromptTail.substring(first + promptLength); + sendPasswordInput(password); + } + } + + private void sendPasswordInput(@NonNull String password) { runOnPool(() -> { - String resultMessage = null; - boolean success = false; try { - if (action != null) { - action.checkResult(); - success = true; - } + writeOperationConsole(fmt("%s\n", password)); } catch (Exception e) { - Log.e(TAG, "Agent result check failed", e); - resultMessage = e.getMessage(); + Log.e(TAG, "Failed to write password input", e); + mainHandler.post(() -> showFailed( + getString(R.string.agent_operation_control_failed), false)); } - killTerminalSession(); - cleanupVM(); - final boolean finalSuccess = success; - final String finalMsg = resultMessage; - runOnUiThread(() -> { - progressSpinner.setVisibility(GONE); - ivStatus.setVisibility(VISIBLE); - btnCancel.setText(android.R.string.ok); - btnCancel.setOnClickListener(v -> finish()); - if (finalSuccess) { - ivStatus.setImageResource(R.drawable.ic_large_success); - tvStatus.setText(R.string.agent_operation_success); - appendLog(getString(R.string.agent_operation_log_success)); - } else { - ivStatus.setImageResource(R.drawable.ic_large_error); - if (finalMsg != null) { - tvStatus.setText(getString(R.string.agent_operation_failed_detail, finalMsg)); - } else { - tvStatus.setText(getString(R.string.agent_operation_failed, exitCode)); - } - appendLog(getString(R.string.agent_operation_log_failed)); - } - }); }); } - private void killTerminalSession() { - if (terminalSession != null) { + private void clearPasswords() { + actionPasswords.clear(); + activeActionIndex = -1; + activePassword = null; + passwordPromptTail = ""; + } + + private void sendOperationCommand(@NonNull String command, boolean failOnError) { + runOnPool(() -> { try { - if (terminalSession.isRunning()) - shellKillProcess(terminalSession.getPid(), SIGHUP); - } catch (Exception ignored) { + writeOperationConsole(fmt("%s\n", command)); + } catch (Exception e) { + Log.e(TAG, "Failed to write operation console", e); + if (failOnError) mainHandler.post(() -> showFailed( + getString(R.string.agent_operation_control_failed), false)); } - terminalSession = null; + }); + } + + private void writeOperationConsole(@NonNull String data) throws Exception { + var stream = agentVM.getOperationConsoleStream(); + if (stream == null) throw new IllegalStateException("Operation console is not configured"); + writeConsole(stream, data); + } + + private void writeConsole(@NonNull String stream, @NonNull String data) throws Exception { + if (vmId == null || vmId.isEmpty()) throw new IllegalStateException("VM is not ready"); + var req = new JSONObject(); + req.put("command", "vm_console_write"); + req.put("vm_id", vmId); + req.put("stream", stream); + req.put("data", data); + var resp = DaemonConnection.getInstance().request(req); + if (!resp.optBoolean("success", false)) + throw new RuntimeException(resp.optString("message", "console write failed")); + } + + @NonNull + private String describeAgentError(@NonNull String code) { + switch (code) { + case "ROOT_NOT_FOUND": + return getString(R.string.agent_operation_error_root_not_found); + case "PASSWD_FAILED": + return getString(R.string.agent_operation_error_password); + case "UNMOUNT_FAILED": + return getString(R.string.agent_operation_error_unmount); + case "SCRIPT_FAILED": + return getString(R.string.agent_operation_error_script); + case "AUTOGROW_DISK_NOT_FOUND": + case "AUTOGROW_PROBE_FAILED": + return getString(R.string.agent_operation_error_autogrow_disk); + case "AUTOGROW_PARTITION_IN_USE": + return getString(R.string.agent_operation_error_autogrow_in_use); + case "PARTITION_GROW_FAILED": + return getString(R.string.agent_operation_error_partition_grow); + case "PARTITION_REREAD_FAILED": + return getString(R.string.agent_operation_error_partition_reread); + case "FILESYSTEM_CHECK_FAILED": + return getString(R.string.agent_operation_error_filesystem_check); + case "FILESYSTEM_MOUNT_FAILED": + return getString(R.string.agent_operation_error_filesystem_mount); + case "FILESYSTEM_GROW_FAILED": + return getString(R.string.agent_operation_error_filesystem_grow); + case "FILESYSTEM_UNMOUNT_FAILED": + return getString(R.string.agent_operation_error_filesystem_unmount); + default: + return getString(R.string.agent_operation_error_unknown, code); } } + private void showSuccess() { + if (resultShown || closing) return; + resultShown = true; + progressSpinner.setVisibility(GONE); + ivStatus.setVisibility(VISIBLE); + ivStatus.setImageResource(R.drawable.ic_large_success); + tvStatus.setText(actionSkipped + ? R.string.agent_operation_success_skipped + : R.string.agent_operation_success); + if (autoFinishOnSuccess) { + // The Linux VM creation chain must return immediately. Never start the optional + // rescue shell on this path or wait for terminal interaction. + finishAgent(true); + return; + } + showResultButtons(); + requestConsoleHandoff(); + } + + private void showFailed(@NonNull String message, boolean logsAvailable) { + if (resultShown || closing) return; + resultShown = true; + progressSpinner.setVisibility(GONE); + ivStatus.setVisibility(VISIBLE); + ivStatus.setImageResource(R.drawable.ic_large_error); + tvStatus.setText(getString(R.string.agent_operation_failed_detail, message)); + btnCancel.setText(android.R.string.ok); + btnCancel.setOnClickListener(v -> finishAgent()); + if (logsAvailable && !vmExited) requestConsoleHandoff(); + } + + private void showResultButtons() { + btnCancel.setText(android.R.string.ok); + btnCancel.setOnClickListener(v -> finishAgent()); + } + + /** Returns the controlling operation shell after the action script has exited. */ + private void requestConsoleHandoff() { + if (vmExited || vmId == null || closing) return; + if (handoffComplete) { + terminalPanel.setInteractive(true); + return; + } + if (handoffRequested) return; + handoffRequested = true; + sendOperationCommand( + "ROOT_DEVICE=$(cat /run/droidvm-root-device 2>/dev/null); " + + "if [ -n \"$ROOT_DEVICE\" ] && ! mountpoint -q /mnt; then " + + "mount -o rw \"$ROOT_DEVICE\" /mnt >/dev/null 2>&1 || true; fi; " + + "if mountpoint -q /mnt; then " + + "printf '\\nDroidVM rescue shell; target root: /mnt\\n'; " + + "else printf '\\nDroidVM rescue shell\\n'; fi; " + + "PS1='droidvm-rescue # '; stty echo; " + + "printf '\\n__DROIDVM_%s:SHELL:READY\\n' AGENT__", + true); + } + + private void finishAgent() { + finishAgent(false); + } + + private void finishAgent(boolean returnSuccess) { + if (closing) return; + closing = true; + btnCancel.setEnabled(false); + terminalPanel.setInteractive(false); + stopConsoleSession(); + tvStatus.setText(R.string.agent_operation_stopping); + runOnPool(() -> { + if (!vmExited) { + try { + writeOperationConsole("stty -echo; sync; " + + "umount /mnt/proc >/dev/null 2>&1 || true; " + + "umount /mnt/dev >/dev/null 2>&1 || true; " + + "umount /mnt >/dev/null 2>&1 || " + + "umount -l /mnt >/dev/null 2>&1 || true; poweroff -f\n"); + } catch (Exception e) { + Log.w(TAG, "Guest shutdown command failed", e); + } + threadSleep(800); + requestStop(); + } + cleanupVM(); + runOnUiThread(() -> finishActivity(returnSuccess)); + }); + } + + private void requestStop() { + if (vmId == null || vmId.isEmpty()) return; + try { + var req = new JSONObject(); + req.put("command", "vm_stop"); + req.put("vm_id", vmId); + DaemonConnection.getInstance().request(req); + } catch (Exception e) { + Log.d(TAG, "VM was already stopped or stop request failed", e); + } + } + + private void onVMFinished(int exitCode) { + if (activityDone) return; + vmExited = true; + terminalPanel.setInteractive(false); + stopConsoleSession(); + if (closing) return; + if (resultShown) { + tvStatus.setText(R.string.agent_operation_vm_stopped); + return; + } + showFailed(getString(R.string.agent_operation_failed, exitCode), false); + } + + @Override + public void onDaemonConnected() { + } + + @Override + public void onDaemonDisconnected() { + if (!activityDone && !closing) + mainHandler.post(() -> showFailed( + getString(R.string.agent_operation_daemon_disconnected), false)); + } + + private void stopConsoleSession() { + if (terminalSession == null) return; + var session = terminalSession; + terminalSession = null; + try { + if (session.isRunning()) shellKillProcess(session.getPid(), SIGHUP); + } catch (Exception ignored) { + } + session.finishIfRunning(); + terminalPanel.clearSession(session); + } + private void cleanupVM() { + if (!cleanupStarted.compareAndSet(false, true)) return; unregisterEventListeners(); - if (vmId != null && !vmId.isEmpty()) { + var id = vmId; + if (id == null || id.isEmpty()) return; + requestStop(); + var conn = DaemonConnection.getInstance(); + boolean stopped = false; + for (int i = 0; i < 50; i++) { try { - var conn = DaemonConnection.getInstance(); - var destroyReq = new JSONObject(); - destroyReq.put("command", "vm_delete"); - destroyReq.put("vm_id", vmId); - conn.request(destroyReq); - Log.i(TAG, fmt("Temporary VM %s destroyed", vmId)); + var statusReq = new JSONObject(); + statusReq.put("command", "vm_status"); + statusReq.put("vm_id", id); + var status = conn.request(statusReq); + if (status.optBoolean("success", false) + && status.optString("state", "").equals("stopped")) { + stopped = true; + break; + } } catch (Exception e) { - Log.w(TAG, fmt("Failed to destroy temporary VM %s", vmId), e); + break; } + threadSleep(100); } - if (agentVM != null) { - try { - agentVM.cleanupVars(); - } catch (Exception e) { - Log.w(TAG, "Failed to cleanup vars", e); - } + if (!stopped) { + Log.w(TAG, fmt("Temporary VM %s did not stop; leaving its daemon record intact", id)); + return; } + try { + var destroyReq = new JSONObject(); + destroyReq.put("command", "vm_delete"); + destroyReq.put("vm_id", id); + var response = conn.request(destroyReq); + if (!response.optBoolean("success", false)) + Log.w(TAG, fmt("Failed to destroy temporary VM %s: %s", + id, response.optString("message", "unknown error"))); + else + Log.i(TAG, fmt("Temporary VM %s destroyed", id)); + } catch (Exception e) { + Log.w(TAG, fmt("Failed to destroy temporary VM %s", id), e); + } + vmId = null; } - private void showFailed(@NonNull String message) { - finished = true; - progressSpinner.setVisibility(GONE); - ivStatus.setVisibility(VISIBLE); - ivStatus.setImageResource(R.drawable.ic_large_error); - tvStatus.setText(message); - btnCancel.setText(android.R.string.ok); - btnCancel.setOnClickListener(v -> finish()); + private void finishActivity(boolean returnSuccess) { + if (activityDone) return; + activityDone = true; + stopConsoleSession(); + if (returnSuccess) setResult(RESULT_OK); + finish(); } private void confirmCancel() { - if (finished) { - finish(); + if (resultShown) { + finishAgent(); return; } new MaterialAlertDialogBuilder(this) .setTitle(R.string.agent_operation_cancel_title) .setMessage(R.string.agent_operation_cancel_message) - .setPositiveButton(android.R.string.ok, (d, w) -> { - finished = true; - runOnPool(() -> { - killTerminalSession(); - cleanupVM(); - }); - finish(); - }) + .setPositiveButton(android.R.string.ok, (d, w) -> finishAgent()) .setNegativeButton(android.R.string.cancel, null) .show(); } private void confirmFinish() { - if (finished) { - finish(); - return; - } - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.agent_operation_cancel_title) - .setMessage(R.string.agent_operation_cancel_message) - .setPositiveButton(android.R.string.ok, (d, w) -> { - finished = true; - runOnPool(() -> { - killTerminalSession(); - cleanupVM(); - }); - finish(); - }) - .setNegativeButton(android.R.string.cancel, null) - .show(); + confirmCancel(); } @Override protected void onDestroy() { super.onDestroy(); - if (!finished) { + clearPasswords(); + if (!activityDone) { + closing = true; runOnPool(() -> { - killTerminalSession(); cleanupVM(); + stopConsoleSession(); }); } } } - diff --git a/app/src/main/java/cn/classfun/droidvm/ui/agent/autogrow/AutoGrowAction.java b/app/src/main/java/cn/classfun/droidvm/ui/agent/autogrow/AutoGrowAction.java new file mode 100644 index 00000000..8688e5f5 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/agent/autogrow/AutoGrowAction.java @@ -0,0 +1,156 @@ +// 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.ui.agent.autogrow; + +import static cn.classfun.droidvm.lib.utils.RunUtils.escapedString; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; + +import cn.classfun.droidvm.ui.agent.base.AgentActionSpec; +import cn.classfun.droidvm.ui.agent.base.AgentVM; +import cn.classfun.droidvm.ui.agent.base.BaseAction; + +/** Grows the physical last data partition and its supported filesystem into trailing space. */ +public final class AutoGrowAction extends BaseAction { + public static final String TYPE = "autogrow"; + + /** Appends an autogrow step targeting the first AgentVM disk (/dev/vda). */ + public AutoGrowAction(@NonNull AgentVM vm) { + this(vm, vm.addAction(TYPE)); + } + + public AutoGrowAction(@NonNull AgentVM vm, @NonNull AgentActionSpec spec) { + super(vm, spec); + if (!spec.hasParam("device")) spec.setParam("device", "/dev/vda"); + } + + public void setDevice(@NonNull String device) { + spec.setParam("device", device); + } + + @NonNull + @Override + protected String buildActionScript() { + var device = spec.getParam("device", "/dev/vda"); + var script = String.join("\n", + "DISK=%s", + "DISK_NAME=${DISK##*/}", + "SYS_DISK=/sys/class/block/$DISK_NAME", + "[ -b \"$DISK\" ] && [ -d \"$SYS_DISK\" ] || fail AUTOGROW_DISK_NOT_FOUND", + // DOS extended partitions are containers, not data partitions. Ignore the container + // when selecting the physical last partition, but extend it before a logical child. + "EXTENDED_DEVICE=$(sfdisk -d \"$DISK\" 2>/dev/null | grep -Ei 'type=(0x)?(5|f|85)(,|$)' | head -n 1 | cut -d' ' -f1)", + "LAST_PART=", + "LAST_SYS=", + "LAST_END=0", + "for SYS_PART in /sys/class/block/\"$DISK_NAME\"*; do", + " [ -f \"$SYS_PART/partition\" ] || continue", + " PART_NAME=${SYS_PART##*/}", + " PART_DEVICE=/dev/$PART_NAME", + " [ \"$PART_DEVICE\" = \"$EXTENDED_DEVICE\" ] && continue", + " PART_START=$(cat \"$SYS_PART/start\") || fail AUTOGROW_PROBE_FAILED", + " PART_SIZE=$(cat \"$SYS_PART/size\") || fail AUTOGROW_PROBE_FAILED", + " PART_END=$((PART_START + PART_SIZE))", + " if [ \"$PART_END\" -gt \"$LAST_END\" ]; then", + " LAST_END=$PART_END", + " LAST_PART=$PART_DEVICE", + " LAST_SYS=$SYS_PART", + " fi", + "done", + "if [ -z \"$LAST_PART\" ]; then", + " skip_action NO_PARTITION", + " return 0", + "fi", + "PART_NUM=$(cat \"$LAST_SYS/partition\") || fail AUTOGROW_PROBE_FAILED", + "BLKID_LINE=$(blkid \"$LAST_PART\" 2>/dev/null || true)", + "FS_TYPE=$(printf '%%s\\n' \"$BLKID_LINE\" | sed -n 's/.* TYPE=\"\\([^\"]*\\)\".*/\\1/p')", + "case \"$FS_TYPE\" in", + " ext2|ext3|ext4|btrfs|f2fs) ;;", + " '') skip_action UNKNOWN_FILESYSTEM; return 0 ;;", + " *) skip_action \"UNSUPPORTED_FILESYSTEM:$FS_TYPE\"; return 0 ;;", + "esac", + "DISK_SECTORS=$(cat \"$SYS_DISK/size\") || fail AUTOGROW_PROBE_FAILED", + "FREE_SECTORS=$((DISK_SECTORS - LAST_END))", + // Ignore alignment and GPT-backup-header sized gaps. sysfs sizes are 512-byte sectors. + "if [ \"$FREE_SECTORS\" -lt 2048 ]; then", + " skip_action NO_TRAILING_SPACE", + " return 0", + "fi", + "if awk -v dev=\"$LAST_PART\" '$1 == dev { found=1 } END { exit !found }' /proc/mounts; then", + " fail AUTOGROW_PARTITION_IN_USE", + "fi", + "OLD_PART_SIZE=$(cat \"$LAST_SYS/size\") || fail AUTOGROW_PROBE_FAILED", + "marker \"AUTOGROW:PARTITION:$LAST_PART:$FS_TYPE:$FREE_SECTORS\"", + "if [ -n \"$EXTENDED_DEVICE\" ] && [ \"$PART_NUM\" -ge 5 ]; then", + " EXTENDED_NAME=${EXTENDED_DEVICE##*/}", + " EXTENDED_NUM=$(cat \"/sys/class/block/$EXTENDED_NAME/partition\") || fail AUTOGROW_PROBE_FAILED", + " command_log \"parted -s -f $DISK resizepart $EXTENDED_NUM 100%%\"", + " parted -s -f \"$DISK\" resizepart \"$EXTENDED_NUM\" 100%%", + " rc=$?", + " marker \"COMMAND:RC:PARTED_EXTENDED:$rc\"", + " [ \"$rc\" -eq 0 ] || fail PARTITION_GROW_FAILED", + "fi", + "command_log \"parted -s -f $DISK resizepart $PART_NUM 100%%\"", + "parted -s -f \"$DISK\" resizepart \"$PART_NUM\" 100%%", + "rc=$?", + "marker \"COMMAND:RC:PARTED:$rc\"", + "[ \"$rc\" -eq 0 ] || fail PARTITION_GROW_FAILED", + "command_log \"partprobe $DISK\"", + "partprobe \"$DISK\"", + "rc=$?", + "marker \"COMMAND:RC:PARTPROBE:$rc\"", + "[ \"$rc\" -eq 0 ] || fail PARTITION_REREAD_FAILED", + "NEW_PART_SIZE=$OLD_PART_SIZE", + "for wait_count in $(seq 1 20); do", + " NEW_PART_SIZE=$(cat \"$LAST_SYS/size\" 2>/dev/null || echo 0)", + " [ \"$NEW_PART_SIZE\" -gt \"$OLD_PART_SIZE\" ] && break", + " sleep 0.1", + "done", + "[ \"$NEW_PART_SIZE\" -gt \"$OLD_PART_SIZE\" ] || fail PARTITION_REREAD_FAILED", + "case \"$FS_TYPE\" in", + " ext2|ext3|ext4)", + " command_log \"e2fsck -pf $LAST_PART\"", + " e2fsck -pf \"$LAST_PART\"", + " FSCK_RC=$?", + " marker \"COMMAND:RC:E2FSCK:$FSCK_RC\"", + " [ \"$FSCK_RC\" -le 1 ] || fail FILESYSTEM_CHECK_FAILED", + " command_log \"resize2fs $LAST_PART\"", + " resize2fs \"$LAST_PART\"", + " rc=$?", + " marker \"COMMAND:RC:RESIZE2FS:$rc\"", + " [ \"$rc\" -eq 0 ] || fail FILESYSTEM_GROW_FAILED", + " ;;", + " btrfs)", + " modprobe btrfs >/dev/null 2>&1 || true", + " mount -t btrfs -o rw \"$LAST_PART\" /mnt-autogrow || fail FILESYSTEM_MOUNT_FAILED", + " command_log \"btrfs filesystem resize max /mnt-autogrow\"", + " btrfs filesystem resize max /mnt-autogrow", + " rc=$?", + " marker \"COMMAND:RC:BTRFS_RESIZE:$rc\"", + " [ \"$rc\" -eq 0 ] || fail FILESYSTEM_GROW_FAILED", + " sync", + " umount /mnt-autogrow || fail FILESYSTEM_UNMOUNT_FAILED", + " ;;", + " f2fs)", + " modprobe f2fs >/dev/null 2>&1 || true", + " command_log \"fsck.f2fs -f $LAST_PART\"", + " fsck.f2fs -f \"$LAST_PART\"", + " rc=$?", + " marker \"COMMAND:RC:F2FS_FSCK:$rc\"", + " [ \"$rc\" -eq 0 ] || fail FILESYSTEM_CHECK_FAILED", + " command_log \"resize.f2fs $LAST_PART\"", + " resize.f2fs \"$LAST_PART\"", + " rc=$?", + " marker \"COMMAND:RC:F2FS_RESIZE:$rc\"", + " [ \"$rc\" -eq 0 ] || fail FILESYSTEM_GROW_FAILED", + " ;;", + "esac", + "sync", + "marker \"AUTOGROW:GROWN:$LAST_PART:$FS_TYPE:$OLD_PART_SIZE:$NEW_PART_SIZE\"", + "" + ); + return fmt(script, escapedString(device)); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/agent/base/AgentActionSpec.java b/app/src/main/java/cn/classfun/droidvm/ui/agent/base/AgentActionSpec.java new file mode 100644 index 00000000..7488eff2 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/agent/base/AgentActionSpec.java @@ -0,0 +1,71 @@ +// 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.ui.agent.base; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Map; + +import cn.classfun.droidvm.lib.store.base.JSONSerialize; +import cn.classfun.droidvm.lib.utils.JsonUtils; + +/** One ordered, serializable operation executed by an {@link AgentVM}. */ +public final class AgentActionSpec implements JSONSerialize { + private final String type; + private final Map params = new HashMap<>(); + + public AgentActionSpec(@NonNull String type) { + var normalized = type.trim().toLowerCase(java.util.Locale.ROOT); + if (!normalized.matches("[a-z][a-z0-9_-]*")) + throw new IllegalArgumentException(fmt("Invalid agent action type: %s", type)); + this.type = normalized; + } + + public AgentActionSpec(@NonNull JSONObject jo) throws JSONException { + this(jo.getString("type")); + if (jo.has("params")) params.putAll(JsonUtils.objectToStringMap(jo, "params")); + } + + @NonNull + public String getType() { + return type; + } + + public void setParam(@NonNull String key, @NonNull String value) { + params.put(key, value); + } + + public boolean hasParam(@NonNull String key) { + return params.containsKey(key); + } + + @Nullable + public String getParam(@NonNull String key, @Nullable String def) { + var value = params.getOrDefault(key, def); + return value == null || value.isEmpty() ? def : value; + } + + public void clearParam(@NonNull String key) { + params.remove(key); + } + + @NonNull + @Override + public JSONObject toJson() throws JSONException { + var out = new JSONObject(); + out.put("type", type); + var paramsObject = new JSONObject(); + for (var entry : params.entrySet()) + paramsObject.put(entry.getKey(), entry.getValue()); + out.put("params", paramsObject); + return out; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/agent/base/AgentPayloadChunks.java b/app/src/main/java/cn/classfun/droidvm/ui/agent/base/AgentPayloadChunks.java new file mode 100644 index 00000000..e589ea95 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/agent/base/AgentPayloadChunks.java @@ -0,0 +1,27 @@ +// 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.ui.agent.base; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.List; + +/** Splits an encoded rescue payload into lines safely below the TTY canonical limit. */ +public final class AgentPayloadChunks { + public static final int MAX_CHUNK_LENGTH = 1024; + + private AgentPayloadChunks() { + } + + @NonNull + public static List split(@NonNull String payload) { + var chunks = new ArrayList(); + for (int start = 0; start < payload.length(); start += MAX_CHUNK_LENGTH) { + int end = Math.min(start + MAX_CHUNK_LENGTH, payload.length()); + chunks.add(payload.substring(start, end)); + } + return chunks; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/agent/base/AgentVM.java b/app/src/main/java/cn/classfun/droidvm/ui/agent/base/AgentVM.java index 0b4ca11e..708dc962 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/agent/base/AgentVM.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/agent/base/AgentVM.java @@ -1,12 +1,11 @@ +// 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.ui.agent.base; -import static cn.classfun.droidvm.lib.Constants.DATA_DIR; import static cn.classfun.droidvm.lib.Constants.PATH_BUILTIN_INITRD; import static cn.classfun.droidvm.lib.Constants.PATH_BUILTIN_KERNEL; -import static cn.classfun.droidvm.lib.utils.FileUtils.shellRemoveTree; -import static cn.classfun.droidvm.lib.utils.RunUtils.escapedString; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; -import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -15,13 +14,14 @@ import org.json.JSONException; import org.json.JSONObject; -import java.io.File; -import java.io.IOException; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Random; +import java.util.regex.Pattern; import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.lib.store.base.JSONSerialize; @@ -30,24 +30,46 @@ import cn.classfun.droidvm.lib.store.disk.DiskStore; import cn.classfun.droidvm.lib.store.vm.BootConfig; import cn.classfun.droidvm.lib.store.vm.LendMthpMode; -import cn.classfun.droidvm.lib.store.vm.SharedDirType; +import cn.classfun.droidvm.lib.store.vm.VMBackend; import cn.classfun.droidvm.lib.store.vm.VMConfig; -import cn.classfun.droidvm.lib.utils.FileUtils; +import cn.classfun.droidvm.lib.store.vm.VMHypervisor; import cn.classfun.droidvm.lib.utils.JsonUtils; public final class AgentVM implements JSONSerialize { - private final static String AGENT_DIR = pathJoin(DATA_DIR, "/usr/share/droidvm/agent"); + private static final Pattern CONSOLE_STREAM_PATTERN = + Pattern.compile("[A-Za-z0-9._-]+"); + private static final Pattern CONSOLE_DEVICE_PATTERN = + Pattern.compile("/dev/[A-Za-z0-9._/-]+"); private List disks = new ArrayList<>(); + private List actions = new ArrayList<>(); private Map vars = new HashMap<>(); - private Map result = null; private String randomId = null; + private String operationConsoleStream = null; + private String operationConsoleDevice = null; + private VMBackend backend; + private VMHypervisor hypervisor; public AgentVM() { + this(VMBackend.DEFAULT, VMHypervisor.AUTO); + } + + public AgentVM( + @NonNull VMBackend backend, + @NonNull VMHypervisor hypervisor + ) { + this.backend = backend; + this.hypervisor = hypervisor; } public AgentVM(@NonNull DiskStore store, @NonNull JSONObject jo) throws JSONException { + this(); if (jo.has("id")) randomId = jo.getString("id"); + if (jo.has("backend")) + backend = VMBackend.valueOf(jo.getString("backend").toUpperCase(Locale.ROOT)); + if (jo.has("hypervisor")) + hypervisor = VMHypervisor.valueOf( + jo.getString("hypervisor").toUpperCase(Locale.ROOT)); if (jo.has("disks")) this.disks = JsonUtils.arrayToList(jo, "disks", v -> { var disk = store.findById((String) v); if (disk == null) throw new JSONException(fmt( @@ -55,8 +77,16 @@ public AgentVM(@NonNull DiskStore store, @NonNull JSONObject jo) throws JSONExce )); return disk; }); + if (jo.has("actions")) this.actions = JsonUtils.arrayToList( + jo, "actions", v -> new AgentActionSpec((JSONObject) v)); if (jo.has("vars")) this.vars = JsonUtils.objectToStringMap(jo, "vars"); + var operationConsole = jo.optJSONObject("operation_console"); + if (operationConsole != null) + setOperationConsole( + operationConsole.getString("stream"), + operationConsole.getString("device") + ); } @NonNull @@ -65,14 +95,26 @@ public JSONObject toJson() throws JSONException { var jo = new JSONObject(); if (randomId != null) jo.put("id", randomId); + jo.put("backend", backend.name().toLowerCase(Locale.ROOT)); + jo.put("hypervisor", hypervisor.name().toLowerCase(Locale.ROOT)); var disksArr = new JSONArray(); for (var disk : disks) disksArr.put(disk.getId().toString()); jo.put("disks", disksArr); + var actionsArr = new JSONArray(); + for (var action : actions) + actionsArr.put(action.toJson()); + jo.put("actions", actionsArr); var varsObj = new JSONObject(); for (var entry : vars.entrySet()) varsObj.put(entry.getKey(), entry.getValue()); jo.put("vars", varsObj); + if (operationConsoleStream != null && operationConsoleDevice != null) { + var operationConsole = new JSONObject(); + operationConsole.put("stream", operationConsoleStream); + operationConsole.put("device", operationConsoleDevice); + jo.put("operation_console", operationConsole); + } return jo; } @@ -93,13 +135,62 @@ private String getName() { return fmt("agent-%s", getRandomId()); } + public void addDisk(@NonNull DiskConfig disk) { + disks.add(disk); + } + + /** Appends an operation; list order is execution order inside the same rescue VM. */ @NonNull - private String getVarsDir() { - return pathJoin(DATA_DIR, "run", getName()); + public AgentActionSpec addAction(@NonNull String type) { + var action = new AgentActionSpec(type); + actions.add(action); + return action; } - public void addDisk(@NonNull DiskConfig disk) { - disks.add(disk); + @NonNull + public List getActions() { + return Collections.unmodifiableList(actions); + } + + @NonNull + public VMBackend getBackend() { + return backend; + } + + public void setBackend(@NonNull VMBackend backend) { + this.backend = backend; + } + + @NonNull + public VMHypervisor getHypervisor() { + return hypervisor; + } + + public void setHypervisor(@NonNull VMHypervisor hypervisor) { + this.hypervisor = hypervisor; + } + + /** + * Declares the one host stream and matching guest tty shared by automation and the user. + * The operation owner chooses both values because neither one can be inferred from a backend. + */ + public void setOperationConsole(@NonNull String stream, @NonNull String device) { + if (!CONSOLE_STREAM_PATTERN.matcher(stream).matches()) + throw new IllegalArgumentException("Invalid operation console stream"); + if (!CONSOLE_DEVICE_PATTERN.matcher(device).matches()) + throw new IllegalArgumentException("Invalid operation console device"); + operationConsoleStream = stream; + operationConsoleDevice = device; + } + + @Nullable + public String getOperationConsoleStream() { + return operationConsoleStream; + } + + @Nullable + public String getOperationConsoleDevice() { + return operationConsoleDevice; } @NonNull @@ -107,15 +198,28 @@ public VMConfig buildVM() { var vm = new VMConfig(); vm.setName(getName()); vm.item.set("temporary", true); + vm.item.set("agent_mode", false); + vm.item.set("backend", backend); + vm.item.set("hypervisor", hypervisor); vm.item.set("cpu_count", 1); - vm.item.set("memory_mb", 384); + // The existing general-purpose initramfs expands to roughly 113 MiB. 320 MiB is the + // measured reliable floor on TCG while keeping a useful margin for filesystem modules. + vm.item.set("memory_mb", 320); + vm.item.set("hugepages", false); + vm.item.set("rng", false); + vm.item.set("balloon", false); + vm.item.set("usb", false); + vm.item.set("audio_enabled", false); vm.item.set(LendMthpMode.KEY, LendMthpMode.DISABLED); var boot = BootConfig.of(vm); boot.setProtocol(BootConfig.Protocol.LINUX); boot.setLinuxSource(BootConfig.LinuxSource.MANUAL); boot.setKernel(PATH_BUILTIN_KERNEL); boot.setInitrd(PATH_BUILTIN_INITRD); - boot.setCmdline("rd.systemd.unit=host-agent.target"); + if (operationConsoleStream == null || operationConsoleDevice == null) + throw new IllegalStateException("Operation console is not configured"); + var console = operationConsoleDevice.substring("/dev/".length()); + boot.setCmdline(fmt("console=%s rdinit=/bin/sh panic=-1", console)); var diskItems = DataItem.newArray(); for (var disk : disks) { var item = DataItem.newObject(); @@ -124,18 +228,7 @@ public VMConfig buildVM() { diskItems.append(item); } vm.item.set("disks", diskItems); - var dirItems = DataItem.newArray(); - var hostDir = DataItem.newObject(); - hostDir.set("path", AGENT_DIR); - hostDir.set("tag", "host"); - hostDir.set("type", SharedDirType.FS); - dirItems.append(hostDir); - var varsDir = DataItem.newObject(); - varsDir.set("path", getVarsDir()); - varsDir.set("tag", "vars"); - varsDir.set("type", SharedDirType.FS); - dirItems.append(varsDir); - vm.item.set("shared_dirs", dirItems); + vm.item.set("networks", DataItem.newArray()); return vm; } @@ -155,60 +248,7 @@ public String getActionVar(@NonNull String key, @Nullable String def) { return val; } - public void cleanupVars() { - shellRemoveTree(getVarsDir()); - } - - public void prepareVars() throws IOException { - var varsDir = getVarsDir(); - if (!new File(varsDir).mkdirs()) - throw new IOException("Failed to create vars dir"); - var sb = new StringBuilder(); - vars.forEach((k, v) -> sb.append(fmt("%s=%s\n", k, escapedString(v)))); - var actionFile = pathJoin(varsDir, "actions.txt"); - FileUtils.writeFile(actionFile, sb.toString()); - } - - @NonNull - private Map readResult() throws IOException { - var resultFile = pathJoin(getVarsDir(), "result.txt"); - var result = new HashMap(); - if (!new File(resultFile).exists()) - throw new IOException("Result file does not exist"); - var lines = FileUtils.readFile(resultFile); - for (var line : lines.split("\n")) { - var idx = line.indexOf('='); - if (idx <= 0) continue; - var key = line.substring(0, idx).trim(); - var value = line.substring(idx + 1).trim(); - result.put(key, value); - } - return result; - } - - @NonNull - private Map getResult() { - if (result == null) { - try { - result = readResult(); - } catch (IOException e) { - throw new RuntimeException("Failed to read result", e); - } - } - return result; - } - - @NonNull - public String getResultItem(@NonNull String key, @NonNull String def) { - var res = getResult(); - if (!res.containsKey(key)) return def; - var val = res.getOrDefault(key, def); - if (val == null || val.isEmpty()) return def; - return val; - } - - public boolean isResultValue(@NonNull String key, @NonNull String expected) { - var val = getResultItem(key, ""); - return val.equals(expected); + public void clearActionVar(@NonNull String key) { + vars.remove(key); } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/agent/base/BaseAction.java b/app/src/main/java/cn/classfun/droidvm/ui/agent/base/BaseAction.java index 61e75783..ad413f0f 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/agent/base/BaseAction.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/agent/base/BaseAction.java @@ -1,16 +1,25 @@ +// 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.ui.agent.base; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import androidx.annotation.NonNull; +import java.util.ArrayList; +import java.util.List; + +import cn.classfun.droidvm.ui.agent.autogrow.AutoGrowAction; import cn.classfun.droidvm.ui.agent.password.PasswordAction; public abstract class BaseAction { protected final AgentVM vm; + protected final AgentActionSpec spec; - public BaseAction(@NonNull AgentVM vm) { + protected BaseAction(@NonNull AgentVM vm, @NonNull AgentActionSpec spec) { this.vm = vm; + this.spec = spec; } @NonNull @@ -19,18 +28,94 @@ public AgentVM getVM() { return vm; } - public abstract void checkResult(); + /** Shell function body executed as one step of the ordered rescue action list. */ + @NonNull + protected abstract String buildActionScript(); + + /** Drops credentials once the command payload has been built. */ + public void clearSecrets() { + } @NonNull - public static BaseAction createAction(@NonNull AgentVM vm) { - var action = vm.getActionVar("ACTION", null); - if (action == null) - throw new IllegalArgumentException("VM: No action specified"); - switch (action) { - case "passwd": - return new PasswordAction(vm); + private static BaseAction createAction( + @NonNull AgentVM vm, + @NonNull AgentActionSpec spec + ) { + switch (spec.getType()) { + case PasswordAction.TYPE: + case "passwd": // Early AgentVM prototype spelling. + return new PasswordAction(vm, spec); + case AutoGrowAction.TYPE: + return new AutoGrowAction(vm, spec); default: - throw new IllegalArgumentException(fmt("VM: Unknown action: %s", action)); + throw new IllegalArgumentException(fmt( + "VM: Unknown action: %s", spec.getType())); + } + } + + /** Restores the ordered action queue, including the old vars/ACTION format. */ + @NonNull + public static List createActions(@NonNull AgentVM vm) { + var out = new ArrayList(); + for (var spec : vm.getActions()) out.add(createAction(vm, spec)); + if (!out.isEmpty()) return out; + + var legacyType = vm.getActionVar("ACTION", null); + if (legacyType == null) + throw new IllegalArgumentException("VM: No action specified"); + var legacy = new AgentActionSpec(legacyType); + if (legacyType.equals("passwd")) { + legacy.setParam("password", vm.getActionVar("PASSWORD", "")); + legacy.setParam("normal_users", vm.getActionVar("PASSWD_NORMAL_USERS", "false")); + } + out.add(createAction(vm, legacy)); + return out; + } + + /** Builds one rescue script that runs every action without rebooting between steps. */ + @NonNull + public static String buildRescueScript(@NonNull List actions) { + if (actions.isEmpty()) throw new IllegalArgumentException("VM: No action specified"); + var script = new StringBuilder(String.join("\n", + "#!/bin/sh", + "marker() { printf '\\n__DROIDVM_AGENT__:%s\\n' \"$1\"; }", + "command_log() { printf '\\n[droidvm] $ %s\\n' \"$1\"; }", + "fail() {", + " code=$1", + " sync", + " umount /mnt/proc >/dev/null 2>&1 || true", + " umount /mnt/dev >/dev/null 2>&1 || true", + " umount /mnt >/dev/null 2>&1 || true", + " umount /mnt-autogrow >/dev/null 2>&1 || true", + " marker \"ACTION:ERROR:$ACTION_INDEX:$ACTION_TYPE:$code\"", + " marker \"RESULT:ERROR:$code\"", + " exit 0", + "}", + "skip_action() {", + " ACTION_SKIPPED=true", + " marker \"ACTION:SKIPPED:$ACTION_INDEX:$ACTION_TYPE:$1\"", + "}", + "mkdir -p /mnt /mnt-autogrow /run", + "" + )); + for (int i = 0; i < actions.size(); i++) { + var action = actions.get(i); + var body = action.buildActionScript(); + script.append(fmt("ACTION_INDEX=%d\n", i)); + script.append(fmt("ACTION_TYPE=%s\n", action.spec.getType())); + script.append("ACTION_SKIPPED=false\n"); + script.append("marker \"ACTION:START:$ACTION_INDEX:$ACTION_TYPE\"\n"); + script.append(fmt("agent_action_%d() {\n", i)); + script.append(body); + if (!body.endsWith("\n")) script.append('\n'); + script.append("}\n"); + script.append(fmt("agent_action_%d\n", i)); + script.append("rc=$?\n"); + script.append("[ $rc -eq 0 ] || fail SCRIPT_FAILED\n"); + script.append("[ \"$ACTION_SKIPPED\" = true ] || ") + .append("marker \"ACTION:OK:$ACTION_INDEX:$ACTION_TYPE\"\n\n"); } + script.append("sync\nmarker RESULT:OK\n"); + return script.toString(); } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/agent/password/ChangePasswordActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/agent/password/ChangePasswordActivity.java index 0e68865a..d1d7efe8 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/agent/password/ChangePasswordActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/agent/password/ChangePasswordActivity.java @@ -1,13 +1,22 @@ +// 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.ui.agent.password; +import static cn.classfun.droidvm.lib.utils.StringUtils.SHELL_SAFE_PASSWORD_SYMBOLS; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.isShellSafePassword; +import static cn.classfun.droidvm.lib.utils.StringUtils.shellSafePasswordFilter; import android.content.Context; import android.content.Intent; import android.os.Bundle; +import android.text.InputFilter; import android.util.Log; import android.widget.TextView; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; @@ -22,12 +31,15 @@ import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.store.vm.VMBackend; +import cn.classfun.droidvm.lib.store.vm.VMHypervisor; import cn.classfun.droidvm.ui.agent.AgentOperationActivity; import cn.classfun.droidvm.ui.agent.base.AgentVM; public final class ChangePasswordActivity extends AppCompatActivity { private static final String TAG = "ChangePasswordActivity"; public static final String EXTRA_DISK_ID = "disk_id"; + private static final String EXTRA_QUICK_PASSWORD = "quick_password"; private TextInputLayout tilPassword; private TextInputEditText etPassword; private TextInputLayout tilConfirmPassword; @@ -38,6 +50,7 @@ public final class ChangePasswordActivity extends AppCompatActivity { private TextView tvDiskName; private MaterialSwitch swNormalUsers; private UUID diskId; + private ActivityResultLauncher operationLauncher; @NonNull public static Intent createIntent(@NonNull Context context, @NonNull UUID diskId) { @@ -46,6 +59,18 @@ public static Intent createIntent(@NonNull Context context, @NonNull UUID diskId return intent; } + /** Skips the confirmation form and immediately changes only the root password. */ + @NonNull + public static Intent createQuickIntent( + @NonNull Context context, + @NonNull UUID diskId, + @NonNull String password + ) { + var intent = createIntent(context, diskId); + intent.putExtra(EXTRA_QUICK_PASSWORD, password); + return intent; + } + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -59,10 +84,15 @@ protected void onCreate(Bundle savedInstanceState) { etConfirmPassword = findViewById(R.id.et_confirm_password); swNormalUsers = findViewById(R.id.sw_normal_users); fabConfirm = findViewById(R.id.fab_confirm); - initialize(); + operationLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> { + setResult(result.getResultCode()); + finish(); + }); + initialize(savedInstanceState == null); } - private void initialize() { + private void initialize(boolean isFreshStart) { collapsingToolbar.setTitle(getString(R.string.change_password_title)); toolbar.setNavigationOnClickListener(v -> finish()); var diskIdStr = getIntent().getStringExtra(EXTRA_DISK_ID); @@ -81,6 +111,23 @@ private void initialize() { return; } tvDiskName.setText(getString(R.string.change_password_disk_label, diskConfig.getName())); + // The password rides through the rescue VM's chpasswd shell script; + // only characters that script may carry can be entered. + etPassword.setFilters(new InputFilter[]{shellSafePasswordFilter()}); + etConfirmPassword.setFilters(new InputFilter[]{shellSafePasswordFilter()}); + var quickPassword = getIntent().getStringExtra(EXTRA_QUICK_PASSWORD); + // Do not retain the password in this Activity's explicit intent. + getIntent().removeExtra(EXTRA_QUICK_PASSWORD); + if (isFreshStart && quickPassword != null && !quickPassword.isEmpty()) { + if (isShellSafePassword(quickPassword)) { + startPasswordChange(quickPassword, false, true); + return; + } + // Never hand an unvetted password to the script; fall back to the form. + Log.w(TAG, "Quick password contains unsupported characters; showing form"); + tilPassword.setError(getString( + R.string.change_password_error_unsafe, SHELL_SAFE_PASSWORD_SYMBOLS)); + } fabConfirm.setOnClickListener(v -> onConfirm()); } @@ -94,11 +141,23 @@ private void onConfirm() { tilPassword.setError(getString(R.string.change_password_error_empty)); return; } + if (!isShellSafePassword(password)) { + tilPassword.setError(getString( + R.string.change_password_error_unsafe, SHELL_SAFE_PASSWORD_SYMBOLS)); + return; + } if (!password.equals(confirmPassword)) { tilConfirmPassword.setError(getString(R.string.change_password_error_mismatch)); return; } - boolean changeNormalUsers = swNormalUsers.isChecked(); + startPasswordChange(password, swNormalUsers.isChecked(), false); + } + + private void startPasswordChange( + @NonNull String password, + boolean changeNormalUsers, + boolean quickMode + ) { var diskStore = new DiskStore(); diskStore.load(this); var diskConfig = diskStore.findById(diskId); @@ -107,13 +166,21 @@ private void onConfirm() { finish(); return; } - var agentVM = new AgentVM(); + // Password rescue deliberately selects QEMU TCG and its human UART. AgentVM carries the + // explicit console mapping so other operations can select a different backend and tty. + var agentVM = new AgentVM(VMBackend.QEMU, VMHypervisor.SOFT); + agentVM.setOperationConsole("uart", "/dev/ttyAMA0"); var action = new PasswordAction(agentVM); action.setPassword(password); action.setChangeNormalUsers(changeNormalUsers); agentVM.addDisk(diskConfig); var intent = AgentOperationActivity.createIntent(this, agentVM); - startActivity(intent); - finish(); + if (quickMode) { + intent.putExtra(AgentOperationActivity.EXTRA_AUTOFINISH_ON_SUCCESS, true); + operationLauncher.launch(intent); + } else { + startActivity(intent); + finish(); + } } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/agent/password/PasswordAction.java b/app/src/main/java/cn/classfun/droidvm/ui/agent/password/PasswordAction.java index 5c0683c1..dd56b729 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/agent/password/PasswordAction.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/agent/password/PasswordAction.java @@ -1,44 +1,98 @@ +// 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.ui.agent.password; +import static cn.classfun.droidvm.lib.utils.RunUtils.escapedString; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + import androidx.annotation.NonNull; +import cn.classfun.droidvm.ui.agent.base.AgentActionSpec; import cn.classfun.droidvm.ui.agent.base.AgentVM; import cn.classfun.droidvm.ui.agent.base.BaseAction; public final class PasswordAction extends BaseAction { + public static final String TYPE = "chpasswd"; + + /** Appends a chpasswd step to the VM's ordered action queue. */ public PasswordAction(@NonNull AgentVM vm) { - super(vm); - if (!vm.hasActionVar("MOUNT_ROOT")) - vm.setActionVar("MOUNT_ROOT", "true"); - if (!vm.hasActionVar("ACTION")) - vm.setActionVar("ACTION", "passwd"); - if (!vm.hasActionVar("PASSWORD")) - vm.setActionVar("PASSWORD", ""); - if (!vm.hasActionVar("PASSWD_NORMAL_USERS")) - vm.setActionVar("PASSWD_NORMAL_USERS", "false"); + this(vm, vm.addAction(TYPE)); + } + + public PasswordAction(@NonNull AgentVM vm, @NonNull AgentActionSpec spec) { + super(vm, spec); + if (!spec.hasParam("password")) spec.setParam("password", ""); + if (!spec.hasParam("normal_users")) spec.setParam("normal_users", "false"); } public void setPassword(@NonNull String password) { - vm.setActionVar("PASSWORD", password); + spec.setParam("password", password); } public void setChangeNormalUsers(boolean change) { - vm.setActionVar("PASSWD_NORMAL_USERS", String.valueOf(change)); + spec.setParam("normal_users", String.valueOf(change)); + } + + @NonNull + public String getPassword() { + return spec.getParam("password", ""); + } + + @NonNull + @Override + protected String buildActionScript() { + var changeNormalUsers = spec.getParam("normal_users", "false"); + var script = String.join("\n", + "CHANGE_NORMAL_USERS=%s", + "FILESYSTEMS=$(blkid)", + "echo \"$FILESYSTEMS\" | grep -q 'TYPE=\"btrfs\"' && modprobe btrfs >/dev/null 2>&1 || true", + "echo \"$FILESYSTEMS\" | grep -q 'TYPE=\"xfs\"' && modprobe xfs >/dev/null 2>&1 || true", + "echo \"$FILESYSTEMS\" | grep -q 'TYPE=\"f2fs\"' && modprobe f2fs >/dev/null 2>&1 || true", + "TARGET_DEVICE=\"\"", + "for dev in $(echo \"$FILESYSTEMS\" | grep -E 'TYPE=\"(ext2|ext3|ext4|btrfs|xfs|f2fs)\"' | cut -d: -f1); do", + " marker \"PROBE:$dev\"", + " if mount -o rw \"$dev\" /mnt >/dev/null 2>&1; then", + " if [ -f /mnt/etc/passwd ]; then", + " TARGET_DEVICE=\"$dev\"", + " break", + " fi", + " umount /mnt >/dev/null 2>&1 || true", + " fi", + "done", + "[ -n \"$TARGET_DEVICE\" ] || fail ROOT_NOT_FOUND", + "printf '%%s\\n' \"$TARGET_DEVICE\" > /run/droidvm-root-device", + "marker \"ROOT:$TARGET_DEVICE\"", + "[ -d /mnt/dev ] || fail PASSWD_FAILED", + "[ -d /mnt/proc ] || fail PASSWD_FAILED", + "mount -o bind /dev /mnt/dev || fail PASSWD_FAILED", + "mount -t proc proc /mnt/proc || fail PASSWD_FAILED", + "change_password() {", + " marker \"PASSWD:$1\"", + " command_log \"LC_ALL=C busybox chroot /mnt /usr/bin/passwd $1\"", + " LC_ALL=C busybox chroot /mnt /usr/bin/passwd \"$1\"", + " rc=$?", + " marker \"COMMAND:RC:PASSWD:$1:$rc\"", + " [ \"$rc\" -eq 0 ] || fail PASSWD_FAILED", + "}", + "change_password root", + "if [ \"$CHANGE_NORMAL_USERS\" = true ]; then", + " for user in $(awk -F: '$3 >= 1000 && $3 < 2000 {print $1}' /mnt/etc/passwd); do", + " change_password \"$user\"", + " done", + "fi", + "sync", + "umount /mnt/proc >/dev/null 2>&1 || fail UNMOUNT_FAILED", + "umount /mnt/dev >/dev/null 2>&1 || fail UNMOUNT_FAILED", + "umount /mnt >/dev/null 2>&1 || fail UNMOUNT_FAILED", + "" + ); + return fmt(script, escapedString(changeNormalUsers)); } @Override - public void checkResult() { - if (!vm.isResultValue("STARTED", "true")) - throw new RuntimeException("VM: Agent script failed to start"); - if (vm.isResultValue("ROOT_NOT_FOUND", "true")) - throw new RuntimeException("VM: Root partition not found"); - if (!vm.isResultValue("ROOT_FOUND", "true")) - throw new RuntimeException("VM: Root partition mount failed"); - if (vm.isResultValue("PASSWD_FAILED", "true")) - throw new RuntimeException("VM: Failed to change password"); - if (!vm.isResultValue("PASSWD_SUCCESS", "true")) - throw new RuntimeException("VM: Unknown error during password change"); - if (!vm.isResultValue("ALL_SUCCESS", "true")) - throw new RuntimeException("VM: Not all operations completed successfully"); + public void clearSecrets() { + spec.clearParam("password"); + vm.clearActionVar("PASSWORD"); // Also scrub a deserialized legacy action. } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/BackingChainLinker.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/BackingChainLinker.java new file mode 100644 index 00000000..66d966de --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/BackingChainLinker.java @@ -0,0 +1,315 @@ +// 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.ui.disk.action; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import static cn.classfun.droidvm.lib.utils.StringUtils.basename; +import static cn.classfun.droidvm.lib.utils.StringUtils.dirname; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; +import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.UUID; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.disk.DiskConfig; +import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.utils.ImageUtils; +import cn.classfun.droidvm.ui.disk.tree.DiskTree; + +/** + * Post-import chain resolution for a just-registered qcow2. The whole backing chain is walked + * FIRST (headers only, fast), then at most one dialog asks about everything found: + *

      + *
    • relative backing paths are rewritten absolute in the header ({@code qemu-img rebase -u}, + * instant, header-only) - qemu resolves them against the overlay's directory but crosvm + * does not, and the registry requires absolute paths anyway;
    • + *
    • parents already registered are linked ({@code parent} field);
    • + *
    • parents that exist on disk but aren't registered are listed in one dialog and, on + * confirmation, registered and linked in chain order;
    • + *
    • a missing parent file stops the walk with a warning - the overlay is registered but + * unusable until the file returns.
    • + *
    + */ +public final class BackingChainLinker { + private static final String TAG = "BackingChainLinker"; + + private BackingChainLinker() { + } + + /** One hop of the walked chain, child-first order. */ + private static final class Hop { + final String childPath; + final String parentPath; + @Nullable + final UUID registeredParent; + + Hop(String childPath, String parentPath, @Nullable UUID registeredParent) { + this.childPath = childPath; + this.parentPath = parentPath; + this.registeredParent = registeredParent; + } + } + + /** + * Lazily reconcile one disk's {@code parent} links with its qcow2 headers, walking upward + * from the disk itself. Registers parents that exist but aren't in the registry and fills in + * missing links for ones that are - silently, with no dialog, because this runs on paths + * where the answer is never in doubt: an image whose backing files are all present is + * exactly the case where linking is correct, and one with a missing backing file cannot be + * used at all (the VM start guard reports that separately). + * + *

    Registries predating the overlay tree, and images rebased outside the app, converge the + * first time they're started or opened in branch management. {@code onDone} always runs on + * the main thread. + */ + public static void repair( + @NonNull Context context, @NonNull UUID diskId, @Nullable Runnable onDone) { + var main = new Handler(Looper.getMainLooper()); + runOnPool(() -> { + try { + var store = new DiskStore(); + store.load(context); + var config = store.findById(diskId); + if (config != null) { + var hops = new ArrayList(); + walkChain(store, config.getFullPath(), hops); + if (!hops.isEmpty()) { + var pending = new ArrayList(); + for (var hop : hops) + if (hop.registeredParent == null) pending.add(hop); + if (!pending.isEmpty()) + registerParents(context, pending, null); + applyKnownLinks(context, hops); + } + } + } catch (Exception e) { + Log.w(TAG, "chain repair failed", e); + } + if (onDone != null) main.post(onDone); + }); + } + + /** Write the {@code parent} links for hops whose parent is (now) registered. */ + private static void applyKnownLinks(@NonNull Context context, @NonNull List hops) { + try { + var store = new DiskStore(); + store.load(context); + boolean changed = false; + for (var hop : hops) { + var child = store.findByPath(hop.childPath); + var parent = store.findByPath(hop.parentPath); + if (child == null || parent == null) continue; + if (!parent.getId().equals(child.getParentId())) { + child.setParentId(parent.getId()); + changed = true; + } + } + if (changed) store.save(context); + } catch (Exception e) { + Log.w(TAG, "link write-back failed", e); + } + } + + /** {@code onUpdate} runs on the main thread on every outcome, so callers can chain on it. */ + public static void link( + @NonNull Context context, @NonNull UUID diskId, @Nullable Runnable onUpdate) { + Runnable done = () -> { + if (onUpdate != null) + new Handler(Looper.getMainLooper()).post(onUpdate); + }; + runOnPool(() -> { + try { + var store = new DiskStore(); + store.load(context); + var config = store.findById(diskId); + if (config == null) { + done.run(); + return; + } + var hops = new ArrayList(); + String missingParent = walkChain(store, config.getFullPath(), hops); + if (hops.isEmpty() && missingParent == null) { + done.run(); + return; + } + + // Apply links that need no confirmation (parent already registered). + boolean changed = false; + for (var hop : hops) { + if (hop.registeredParent == null) continue; + var child = store.findByPath(hop.childPath); + if (child != null + && !hop.registeredParent.equals(child.getParentId())) { + child.setParentId(hop.registeredParent); + changed = true; + } + } + if (changed) store.save(context); + + var toImport = new ArrayList(); + for (var hop : hops) + if (hop.registeredParent == null) toImport.add(hop); + + final var missing = missingParent; + new Handler(Looper.getMainLooper()).post(() -> { + if (missing != null) { + new MaterialAlertDialogBuilder(context) + .setTitle(R.string.disk_tree_broken_parent) + .setMessage(context.getString( + R.string.disk_chain_missing_parent, missing)) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + if (toImport.isEmpty()) { + if (onUpdate != null) onUpdate.run(); + return; + } + var names = new StringBuilder(); + for (var hop : toImport) + names.append("\n- ").append(basename(hop.parentPath)); + new MaterialAlertDialogBuilder(context) + .setTitle(R.string.disk_chain_import_title) + .setMessage(context.getString( + R.string.disk_chain_import_message, names.toString())) + .setPositiveButton(R.string.disk_chain_import_confirm, (d, w) -> + runOnPool(() -> registerParents(context, toImport, onUpdate))) + .setNegativeButton(android.R.string.cancel, (d, w) -> { + if (onUpdate != null) onUpdate.run(); + }) + .show(); + }); + } catch (Exception e) { + Log.w(TAG, "backing chain link failed", e); + done.run(); + } + }); + } + + /** + * Walk the chain upward from {@code startPath}, absolutizing headers as it goes. Fills + * {@code hops} child-first; returns the path of a missing parent file, or null. + */ + @Nullable + private static String walkChain( + @NonNull DiskStore store, @NonNull String startPath, @NonNull List hops) { + var current = startPath; + var seen = new HashSet(); + for (int depth = 0; depth < DiskTree.MAX_DEPTH && seen.add(current); depth++) { + String backing; + String backingRaw; + try { + var info = ImageUtils.getImageInfo(current); + backingRaw = info.optString("backing-filename", ""); + if (backingRaw.isEmpty()) return null; + backing = info.optString("full-backing-filename", backingRaw); + } catch (Exception e) { + return null; // unreadable image - nothing to link + } + if (!backing.startsWith("/")) + backing = pathJoin(dirname(current), backing); + if (!backingRaw.equals(backing)) + rebaseAbsolute(current, backing); + if (!new File(backing).exists()) + return backing; + var parent = store.findByPath(backing); + hops.add(new Hop(current, backing, + parent == null ? null : parent.getId())); + current = backing; + } + return null; + } + + /** Header-only rewrite to an absolute backing path; content-identical, so -u is correct. */ + private static void rebaseAbsolute(@NonNull String overlay, @NonNull String absBacking) { + try { + ImageUtils.rebaseBacking(overlay, absBacking); + } catch (Exception e) { + Log.w(TAG, fmt("rebase -u failed for %s", overlay), e); + } + } + + /** + * Repair every registered disk's chain links, for a whole-VM pre-start pass. Blocking; call + * off the main thread. + */ + public static void repairAllBlocking( + @NonNull Context context, @NonNull List paths) { + try { + var store = new DiskStore(); + store.load(context); + var hops = new ArrayList(); + for (var path : paths) { + var config = store.findByPath(path); + if (config != null) walkChain(store, config.getFullPath(), hops); + } + if (hops.isEmpty()) return; + var pending = new ArrayList(); + for (var hop : hops) + if (hop.registeredParent == null) pending.add(hop); + if (!pending.isEmpty()) registerParents(context, pending, null); + applyKnownLinks(context, hops); + } catch (Exception e) { + Log.w(TAG, "bulk chain repair failed", e); + } + } + + /** Register the unregistered parents (root-first so links resolve) and connect the chain. */ + private static void registerParents( + @NonNull Context context, @NonNull List toImport, @Nullable Runnable onUpdate) { + try { + var store = new DiskStore(); + store.load(context); + for (int i = toImport.size() - 1; i >= 0; i--) { + var hop = toImport.get(i); + var parent = store.findByPath(hop.parentPath); + if (parent == null) { + parent = new DiskConfig(); + parent.setName(basename(hop.parentPath)); + parent.item.set("folder", dirname(hop.parentPath)); + // The parent may itself be an overlay whose own parent was walked later + // in the chain (i.e. earlier in this loop, since we go root-first). + var grand = store.findByPath(grandparentOf(hop.parentPath)); + if (grand != null) parent.setParentId(grand.getId()); + store.add(parent); + } + var child = store.findByPath(hop.childPath); + if (child != null) child.setParentId(parent.getId()); + } + store.save(context); + } catch (Exception e) { + Log.w(TAG, "parent registration failed", e); + } + if (onUpdate != null) + new Handler(Looper.getMainLooper()).post(onUpdate); + } + + @NonNull + private static String grandparentOf(@NonNull String path) { + try { + var info = ImageUtils.getImageInfo(path); + var backing = info.optString("full-backing-filename", + info.optString("backing-filename", "")); + if (!backing.isEmpty() && !backing.startsWith("/")) + backing = pathJoin(dirname(path), backing); + return backing; + } catch (Exception e) { + return ""; + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskActionDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskActionDialog.java index 99d71726..400b772e 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskActionDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskActionDialog.java @@ -1,5 +1,10 @@ +// 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.ui.disk.action; +import static cn.classfun.droidvm.lib.utils.StringUtils.bulletList; + import static android.widget.Toast.LENGTH_SHORT; import static cn.classfun.droidvm.lib.utils.AssetUtils.getPrebuiltBinaryPath; import static cn.classfun.droidvm.lib.utils.RunUtils.runList; @@ -27,6 +32,7 @@ import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.IdRes; +import androidx.annotation.MenuRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -34,18 +40,33 @@ import org.json.JSONObject; +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; import java.util.function.Consumer; import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.store.disk.DiskConfig; import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.store.vm.VMStore; +import cn.classfun.droidvm.ui.disk.tree.AttachmentCursor; +import cn.classfun.droidvm.ui.disk.tree.AttachmentCursors; +import cn.classfun.droidvm.ui.disk.tree.AttachmentCursors.LiveRows; +import cn.classfun.droidvm.ui.disk.tree.CursorPlan; +import cn.classfun.droidvm.ui.disk.tree.CursorPlanText; +import cn.classfun.droidvm.ui.disk.tree.TreeShape; +import cn.classfun.droidvm.ui.vm.VmRunningQuery; import cn.classfun.droidvm.lib.ui.MenuDialogBuilder; import cn.classfun.droidvm.lib.utils.ImageUtils; import cn.classfun.droidvm.ui.agent.password.ChangePasswordActivity; +import cn.classfun.droidvm.ui.disk.create.DiskCompress; import cn.classfun.droidvm.ui.disk.create.DiskCreateActivity; import cn.classfun.droidvm.ui.disk.download.ImportURLActivity; import cn.classfun.droidvm.ui.disk.images.ImportImagesActivity; import cn.classfun.droidvm.ui.disk.lxc.ImportLxcImagesActivity; +import cn.classfun.droidvm.ui.disk.operation.OptimizeCompression; public final class DiskActionDialog { private final static String TAG = "DiskActionDialog"; @@ -75,39 +96,414 @@ public DiskActionDialog( this.activityLauncher = activityLauncher; } + /** Source menu for the disk list and the disk-info action grid derived from it. */ + @MenuRes + public static int getMenuResId(@NonNull DiskConfig config) { + if (!DiskConfig.supportsExtraOperations(config.getFormat())) + return R.menu.menu_disk_actions_simple; + if (config.getParentId() != null) + return R.menu.menu_disk_actions_overlay; + return R.menu.menu_disk_actions; + } + + /** Actions that rewrite bytes in the selected image and are unsafe while it has overlays. */ + public static boolean modifiesDiskContent(@IdRes int id) { + return id == R.id.menu_disk_resize + || id == R.id.menu_disk_convert + || id == R.id.menu_disk_optimize + || id == R.id.menu_disk_change_password; + } + public boolean diskMenuOnClick(@NonNull DiskConfig config, @IdRes int id) { + if (!isDiskAction(id)) return false; + Runnable action = () -> performDiskAction(config, id); + if (modifiesDiskContent(id)) + guardUnlocked(config, action); + else + action.run(); + return true; + } + + private static boolean isDiskAction(@IdRes int id) { + return modifiesDiskContent(id) + || id == R.id.menu_disk_delete + || id == R.id.menu_disk_create_increment + || id == R.id.menu_disk_merge + || id == R.id.menu_disk_flatten + || id == R.id.menu_disk_reset + || id == R.id.menu_disk_show_info + || id == R.id.menu_disk_clone; + } + + private void performDiskAction(@NonNull DiskConfig config, @IdRes int id) { if (id == R.id.menu_disk_resize) { new DiskResizeDialog(context, config); - return true; } else if (id == R.id.menu_disk_convert) { - var convert = new DiskSetFormatDialog(context, config); - convert.show(); + new DiskSetFormatDialog(context, config).show(); } else if (id == R.id.menu_disk_optimize) { tryOptimize(config); - return true; } else if (id == R.id.menu_disk_delete) { - confirmDelete(config); - return true; + confirmDelete(config, null, null); } else if (id == R.id.menu_disk_create_increment) { - var intent = new Intent(context, DiskCreateActivity.class); - intent.putExtra(DiskCreateActivity.EXTRA_BACKING_ID, config.getId().toString()); - launchActivity(intent); - return true; + // Snapshot-feel path: one name field, instant create. Advanced (size, compression, + // encryption) falls through to the full create screen in backing mode. + new DiskOverlayCreateDialog(context, config, onUpdate, () -> { + var intent = new Intent(context, DiskCreateActivity.class); + intent.putExtra(DiskCreateActivity.EXTRA_BACKING_ID, config.getId().toString()); + launchActivity(intent); + }).show(); + } else if (id == R.id.menu_disk_merge) { + tryMerge(config, null, null); + } else if (id == R.id.menu_disk_flatten) { + tryFlatten(config, null); + } else if (id == R.id.menu_disk_reset) { + tryReset(config, null, null); } else if (id == R.id.menu_disk_show_info) { showMoreInfo(config); - return true; } else if (id == R.id.menu_disk_clone) { new DiskCloneDialog(context, config).show(); - return true; } else if (id == R.id.menu_disk_change_password) { var intent = ChangePasswordActivity.createIntent(context, config.getId()); launchActivity(intent); - return true; } - return false; + } + + /** + * One family, loaded fresh with everything a tree operation needs to decide and to explain + * itself: the registry, the VM store, the parent links as a {@link TreeShape}, and every + * attachment cursor on the family with its VM's run state. Blocking; build off the main + * thread. + */ + private final class Family { + final DiskStore disks = new DiskStore(); + final VMStore vms = new VMStore(); + final DiskConfig self; + final TreeShape shape; + final Set ids; + final List cursors; + + Family(@NonNull DiskConfig config, @Nullable LiveRows live) { + if (!disks.load(context)) + throw new IllegalStateException( + context.getString(R.string.disk_dependency_update_failed)); + var found = disks.findById(config.getId()); + if (found == null) + throw new IllegalStateException( + context.getString(R.string.disk_tree_not_registered)); + self = found; + vms.load(vms, context); // a missing store just means no VMs yet + shape = TreeShape.of(disks); + ids = shape.familyOf(self.getId()); + var inUse = VmRunningQuery.inUseAmong( + AttachmentCursors.allVmNames(vms, live == null ? null : live.vmName)); + cursors = AttachmentCursors.collect(disks, vms, ids, live, inUse); + } + + /** Names of VMs that are not stopped and attach anything in this family. */ + @NonNull + List inUseVmNames() { + return AttachmentCursors.pinnedVmNames(cursors); + } + } + + /** + * Merge the overlay's changes down into its base ("delete the snapshot, keep the current + * state"). All conditions are checked and every consequence is stated in ONE confirmation + * before anything runs; the data merge and its registry/VM follow-up (children re-based + * onto the base, attachments re-pointed, overlay deleted last) then run unattended in + * {@code DiskOperationActivity}. Requires the overlay to be its base's only child - commit + * rewrites the base, which would corrupt sibling overlays - and the whole family's VMs off. + * + * @param live the disk editor's unsaved rows when opened from one; their cursors move + * silently, and the editor's own saved slots are rewritten without being + * announced + * @param onConfirmed runs (main thread) only once the user confirms, never on cancel + */ + public void tryMerge( + @NonNull DiskConfig config, @Nullable LiveRows live, @Nullable Runnable onConfirmed) { + runOnPool(() -> { + try { + var fam = new Family(config, live); + var self = fam.self; + var parentId = fam.shape.parentOf(self.getId()); + var parent = parentId == null ? null : fam.disks.findById(parentId); + if (parent == null) { + fail(context.getString(R.string.disk_merge_not_overlay)); + return; + } + if (fam.disks.childrenOf(parent.getId()).size() != 1) { + fail(context.getString(R.string.disk_merge_siblings, parent.getName())); + return; + } + var inUse = fam.inUseVmNames(); + if (!inUse.isEmpty()) { + fail(context.getString(R.string.disk_family_vm_running, bulletList(inUse))); + return; + } + var plan = CursorPlan.reconcile(fam.cursors, List.of(), + fam.shape, fam.shape.withMerged(self.getId())); + int childCount = fam.disks.childrenOf(self.getId()).size(); + var message = new StringBuilder(context.getString( + R.string.disk_merge_confirm, self.getName(), parent.getName())); + if (childCount > 0) + message.append(context.getString( + R.string.disk_merge_confirm_children, childCount, parent.getName())); + // Attachments on the base keep their path but get the overlay's content. + var rewritten = new ArrayList(); + for (var c : fam.cursors) + if (c.isAnnounced() && parent.getId().equals(c.nodeId)) + rewritten.add(CursorPlanText.rewrittenLine(context, c, self.getName())); + message.append(CursorPlanText.describe( + context, plan.announcedChanges(), rewritten)); + mainLooper.post(() -> new MaterialAlertDialogBuilder(context) + .setTitle(R.string.disk_merge) + .setMessage(message) + .setPositiveButton(android.R.string.ok, (d, w) -> { + try { + var obj = new JSONObject(); + obj.put("action", "commit"); + context.startActivity(createIntent(context, config.getId(), obj)); + if (onConfirmed != null) onConfirmed.run(); + } catch (Exception e) { + Log.e(TAG, "Failed to start commit", e); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show()); + } catch (Exception e) { + Log.w(TAG, "merge pre-checks failed", e); + fail(String.valueOf(e.getMessage())); + } + }); + } + + /** + * Make the overlay standalone by copying its complete backing-chain view to a temporary image + * and replacing the overlay only after that copy succeeds ("take the branch with you"). + * Sibling overlays never matter; the family's VMs must be off during the replacement. No + * attachment moves: the path and the children's backing headers stay valid. + * + * @param onConfirmed runs (main thread) only once the user confirms, never on cancel + */ + public void tryFlatten(@NonNull DiskConfig config, @Nullable Runnable onConfirmed) { + runOnPool(() -> { + try { + var fam = new Family(config, null); + var self = fam.self; + var parentId = fam.shape.parentOf(self.getId()); + var parent = parentId == null ? null : fam.disks.findById(parentId); + if (parent == null) { + fail(context.getString(R.string.disk_merge_not_overlay)); + return; + } + var inUse = fam.inUseVmNames(); + if (!inUse.isEmpty()) { + fail(context.getString(R.string.disk_family_vm_running, bulletList(inUse))); + return; + } + var message = context.getString( + R.string.disk_flatten_confirm, self.getName(), parent.getName()); + mainLooper.post(() -> new MaterialAlertDialogBuilder(context) + .setTitle(R.string.disk_flatten) + .setMessage(message) + .setPositiveButton(android.R.string.ok, (d, w) -> { + try { + var obj = new JSONObject(); + obj.put("action", "flatten"); + context.startActivity(createIntent(context, config.getId(), obj)); + if (onConfirmed != null) onConfirmed.run(); + } catch (Exception e) { + Log.e(TAG, "Failed to start flatten", e); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show()); + } catch (Exception e) { + Log.w(TAG, "flatten pre-checks failed", e); + fail(String.valueOf(e.getMessage())); + } + }); + } + + /** + * Throw away everything written into a leaf overlay and start it over as a fresh, empty + * overlay of the same base ("roll back to the snapshot"). qemu-img has no command for that, + * so the overlay is recreated: a new header-only image is written beside it, carrying the + * same backing link, virtual size, cluster size and compression type, and then renamed over + * the original - an atomic swap, so a failure leaves the old file untouched. The path never + * changes, so no VM slot moves and nothing is announced beyond "its content resets". + * + *

    Only for a writable leaf: an overlay with overlays of its own is their base, and an + * encrypted one cannot be recreated without its key. The VMs attaching it must be off. + * + * @param onConfirmed runs (main thread) after the swap succeeded, never on cancel or failure + */ + public void tryReset( + @NonNull DiskConfig config, @Nullable LiveRows live, @Nullable Runnable onConfirmed) { + runOnPool(() -> { + try { + var fam = new Family(config, live); + var self = fam.self; + var parentId = fam.shape.parentOf(self.getId()); + var parent = parentId == null ? null : fam.disks.findById(parentId); + if (parent == null) { + fail(context.getString(R.string.disk_merge_not_overlay)); + return; + } + if (fam.shape.hasChildren(self.getId())) { + fail(context.getString(R.string.disk_reset_has_children, self.getName())); + return; + } + var pinned = new ArrayList(); + var rewritten = new ArrayList(); + for (var c : fam.cursors) { + if (!self.getId().equals(c.nodeId)) continue; + if (c.pinned) pinned.add(c); + if (c.isAnnounced()) + rewritten.add(CursorPlanText.rewrittenLine(context, c, parent.getName())); + } + if (!pinned.isEmpty()) { + fail(CursorPlanText.pinnedMessage(context, pinned)); + return; + } + var info = ImageUtils.getImageInfo(self.getFullPath()); + if (info.optBoolean("encrypted", false)) { + fail(context.getString(R.string.disk_reset_encrypted, self.getName())); + return; + } + var message = context.getString( + R.string.disk_reset_confirm, self.getName(), parent.getName()) + + CursorPlanText.describe(context, List.of(), rewritten); + mainLooper.post(() -> new MaterialAlertDialogBuilder(context) + .setTitle(R.string.disk_reset) + .setMessage(message) + .setPositiveButton(android.R.string.ok, (d, w) -> + runOnPool(() -> resetOverlay(self, parent, info, onConfirmed))) + .setNegativeButton(android.R.string.cancel, null) + .show()); + } catch (Exception e) { + Log.w(TAG, "reset pre-checks failed", e); + fail(String.valueOf(e.getMessage())); + } + }); + } + + /** Recreate {@code self} empty on {@code parent} beside itself, then swap it into place. */ + private void resetOverlay( + @NonNull DiskConfig self, + @NonNull DiskConfig parent, + @NonNull JSONObject info, + @Nullable Runnable onConfirmed + ) { + var path = self.getFullPath(); + var tmp = fmt("%s.reset.tmp", path); + try { + var parentPath = parent.getFullPath(); + String backingFormat; + try { + backingFormat = ImageUtils.getImageInfo(parentPath).optString("format", "qcow2"); + } catch (Exception e) { + backingFormat = "qcow2"; + } + var args = new ArrayList(List.of( + getPrebuiltBinaryPath("qemu-img"), "create", + "--format", "qcow2", + "--backing", parentPath, + "--backing-format", backingFormat)); + // Keep the image's own layout choices so the reset overlay behaves like the old one. + var opts = new ArrayList(); + long cluster = info.optLong("cluster-size", 0); + if (cluster > 0) opts.add(fmt("cluster_size=%d", cluster)); + var specific = info.optJSONObject("format-specific"); + var data = specific == null ? null : specific.optJSONObject("data"); + var compression = data == null ? "" : data.optString("compression-type", ""); + if (!compression.isEmpty()) opts.add(fmt("compression_type=%s", compression)); + if (!opts.isEmpty()) { + args.add("-o"); + args.add(String.join(",", opts)); + } + args.add(tmp); + long size = info.optLong("virtual-size", 0); + if (size > 0) args.add(String.valueOf(size)); + var result = runList(args.toArray(new String[0])); + if (!result.isSuccess()) { + result.printLog(TAG); + runList("rm", "-f", tmp); + fail(context.getString(R.string.disk_reset_failed)); + return; + } + if (!new File(tmp).renameTo(new File(path))) { + var moved = runList("mv", "-f", tmp, path); + if (!moved.isSuccess()) { + moved.printLog(TAG); + runList("rm", "-f", tmp); + fail(context.getString(R.string.disk_reset_failed)); + return; + } + } + mainLooper.post(() -> { + Toast.makeText(context, + context.getString(R.string.disk_reset_done, self.getName()), + LENGTH_SHORT).show(); + if (onUpdate != null) onUpdate.run(); + if (onConfirmed != null) onConfirmed.run(); + }); + } catch (Exception e) { + Log.e(TAG, "overlay reset failed", e); + runList("rm", "-f", tmp); + fail(context.getString(R.string.disk_reset_failed)); + } + } + + private void fail(@Nullable String message) { + mainLooper.post(() -> new MaterialAlertDialogBuilder(context) + .setMessage(message == null ? "?" : message) + .setPositiveButton(android.R.string.ok, null) + .show()); + } + + /** + * Rewriting a disk that other images overlay would shift the ground under those overlays. + * Tree-aware operations (create, merge, flatten and delete) deliberately remain available; + * only byte-mutating actions pass through this guard. The registry read happens off the main + * thread and fails closed. + */ + private void guardUnlocked(@NonNull DiskConfig config, @NonNull Runnable action) { + runOnPool(() -> { + int children = -1; + try { + var store = new DiskStore(); + if (store.load(context)) + children = store.childrenOf(config.getId()).size(); + } catch (Exception e) { + Log.w(TAG, "Failed to check disk children", e); + } + final int n = children; + mainLooper.post(() -> { + if (n == 0) { + action.run(); + return; + } + if (n < 0) { + Toast.makeText(context, R.string.disk_info_load_failed, LENGTH_SHORT).show(); + return; + } + new MaterialAlertDialogBuilder(context) + .setTitle(R.string.disk_locked_title) + .setMessage(context.getString( + R.string.disk_locked_message, config.getName(), n)) + .setPositiveButton(android.R.string.ok, null) + .show(); + }); + }); } public void tryOptimize(@NonNull DiskConfig config) { + // Target compression comes from the preferred-compression setting (or its ask prompt). + OptimizeCompression.resolve(context, () -> {}, compress -> tryOptimize(config, compress)); + } + + private void tryOptimize(@NonNull DiskConfig config, @NonNull DiskCompress compress) { Consumer invoke = obj -> { try { var intent = createIntent(context, config.getId(), obj); @@ -121,7 +517,7 @@ public void tryOptimize(@NonNull DiskConfig config) { try { var info = ImageUtils.getImageInfo(config.getFullPath()); obj.put("action", "convert"); - obj.put("keep_compress", true); // preserve compression when optimizing + obj.put("compress", compress.value()); obj.put("format", info.getString("format")); if (info.has("backing-filename")) obj.put("backing_path", info.getString("backing-filename")); @@ -255,6 +651,9 @@ public DiskConfig onFileImported(Uri uri) { }); if (this.onUpdate != null) this.onUpdate.run(); + // Resolve the imported image's backing chain: link/offer-to-import parents, + // absolutize relative backing paths. Prompts (if any) come as a single dialog. + BackingChainLinker.link(context, config.getId(), this.onUpdate); }); return config; } @@ -266,28 +665,103 @@ private void launchActivity(@NonNull Intent intent) { context.startActivity(intent); } - public void confirmDelete(@NonNull DiskConfig config) { + /** + * Delete a disk and everything overlaying it. An overlay holds only differences against its + * base, so a base cannot go without taking its descendants with it - deleting is therefore + * always a whole-subtree operation (unlike merge, which preserves the data by writing it + * down into the base first and can re-link the survivors). The confirmation lists every disk + * that will go, says so as "delete the entire tree" when the target is a family root, and + * states where other VMs' attachments move (to the nearest surviving base, read-only when + * that base still has overlays or another VM holds it; removed when nothing is left). + * + * @param live see {@link #tryMerge} + * @param onConfirmed runs (main thread) after the registry is written, never on cancel + */ + public void confirmDelete( + @NonNull DiskConfig config, @Nullable LiveRows live, @Nullable Runnable onConfirmed) { + runOnPool(() -> { + try { + var fam = new Family(config, live); + var self = fam.self; + // BFS order, self first; deletion goes leaves-first, i.e. reversed. + var subtree = new ArrayList<>(fam.shape.subtreeOf(self.getId())); + var after = fam.shape.without(fam.shape.subtreeOf(self.getId())); + var plan = CursorPlan.reconcile(fam.cursors, List.of(), fam.shape, after); + if (plan.isRefused()) { + fail(CursorPlanText.pinnedMessage(context, plan.refused)); + return; + } + boolean isRoot = fam.shape.parentOf(self.getId()) == null; + var names = new ArrayList(); + var paths = new ArrayList(); + for (var id : subtree) { + names.add(String.valueOf(fam.shape.nameOf(id))); + paths.add(String.valueOf(fam.shape.pathOf(id))); + } + mainLooper.post(() -> + showDeleteDialog(subtree, names, paths, isRoot, plan, onConfirmed)); + } catch (Exception e) { + Log.w(TAG, "delete pre-checks failed", e); + fail(String.valueOf(e.getMessage())); + } + }); + } + + private void showDeleteDialog( + @NonNull List subtree, + @NonNull List names, + @NonNull List paths, + boolean isRoot, + @NonNull CursorPlan plan, + @Nullable Runnable onConfirmed + ) { var layout = new LinearLayout(context); var checkBox = new CheckBox(context); checkBox.setText(R.string.disk_delete_file); int pad = (int) (16 * context.getResources().getDisplayMetrics().density); layout.setPadding(pad, 0, pad, 0); layout.addView(checkBox); + var message = new StringBuilder(); + if (subtree.size() > 1) { + message.append(context.getString( + isRoot ? R.string.disk_delete_tree_message + : R.string.disk_delete_subtree_message, bulletList(names))); + } else { + message.append(context.getString(R.string.disk_delete_confirm)); + } + message.append(CursorPlanText.describe(context, plan.announcedChanges())); DialogInterface.OnClickListener onclick = (d, w) -> { boolean isChecked = checkBox.isChecked(); - var store = new DiskStore(); runOnPool(() -> { - if (isChecked) runList("rm", "-f", config.getFullPath()); - store.load(context); - store.removeById(config.getId()); - store.save(context); + var store = new DiskStore(); + if (!store.load(context) || !DiskDependencyUpdater.applyPlan(context, plan)) { + fail(context.getString(R.string.disk_dependency_update_failed)); + return; + } + // Registry first, leaves first. Files stay present until both VM references and + // the registry have been saved, so an I/O failure cannot create dangling slots. + for (int i = subtree.size() - 1; i >= 0; i--) + store.removeById(subtree.get(i)); + if (!store.save(context)) { + fail(context.getString(R.string.disk_dependency_update_failed)); + return; + } + if (isChecked) { + for (int i = paths.size() - 1; i >= 0; i--) + runList("rm", "-f", paths.get(i)); + } if (this.onUpdate != null) - this.onUpdate.run(); + mainLooper.post(this.onUpdate); + // After the registry is written, so a listener re-reading it (e.g. to redo the + // lock state of a disk that just lost its last overlay) sees the new truth. + if (onConfirmed != null) mainLooper.post(onConfirmed); }); }; new MaterialAlertDialogBuilder(context) - .setTitle(config.getName()) - .setMessage(R.string.disk_delete_confirm) + .setTitle(subtree.size() > 1 && isRoot + ? context.getString(R.string.disk_delete_tree_title) + : names.get(0)) + .setMessage(message) .setView(layout) .setPositiveButton(R.string.vm_delete, onclick) .setNegativeButton(android.R.string.cancel, null) diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskCloneDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskCloneDialog.java index e64c8100..24406b1c 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskCloneDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskCloneDialog.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.ui.disk.action; import static cn.classfun.droidvm.lib.utils.FileUtils.shellCheckExists; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskDependencyUpdater.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskDependencyUpdater.java new file mode 100644 index 00000000..075952a4 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskDependencyUpdater.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.ui.disk.action; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; + +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.vm.VMStore; +import cn.classfun.droidvm.ui.disk.tree.CursorPlan; + +/** + * Writes the persisted half of a {@link CursorPlan} - the slots of other VMs, and the saved + * slots of the VM being edited - into the VM store. The editor applies its own in-memory rows + * separately, when its branch panel closes. + */ +public final class DiskDependencyUpdater { + private static final String TAG = "DiskDependencyUpdater"; + + private DiskDependencyUpdater() { + } + + /** + * Returns only after the updated VMStore is durably saved; failures are fail-closed so the + * caller never deletes a file a slot still points at. + */ + public static boolean applyPlan(@NonNull Context context, @NonNull CursorPlan plan) { + var changes = plan.persistedChanges(); + if (changes.isEmpty()) return true; + var vmStore = new VMStore(); + if (!vmStore.load(vmStore, context)) { + Log.e(TAG, "Failed to load VM store before updating disk dependencies"); + return false; + } + var byVm = new HashMap>(); + for (var c : changes) { + if (c.from.vmId == null) continue; + byVm.computeIfAbsent(c.from.vmId, k -> new ArrayList<>()).add(c); + } + boolean changed = false; + for (var e : byVm.entrySet()) { + var vm = vmStore.findById(e.getKey()); + if (vm == null) continue; + var disks = vm.item.opt("disks", null); + if (disks == null || !disks.is(DataItem.Type.ARRAY)) continue; + changed |= applyToDisks(disks, e.getValue()); + } + if (!changed) return true; + if (vmStore.save(context)) return true; + Log.e(TAG, "Failed to save redirected VM disk dependencies"); + return false; + } + + /** + * Pure slot rewrite for one VM, kept separate for deterministic unit coverage. Highest slot + * first so a removal never shifts a slot still to be visited; a slot whose path no longer + * matches what the plan was computed from is left alone (something else changed it since). + * Read-only is only ever added, never taken away. + */ + static boolean applyToDisks(@NonNull DataItem disks, @NonNull List changes) { + var ordered = new ArrayList<>(changes); + ordered.sort((a, b) -> Integer.compare(b.from.slot, a.from.slot)); + boolean changed = false; + for (var change : ordered) { + int slot = change.from.slot; + if (slot < 0 || slot >= disks.size()) continue; + var disk = disks.get(slot); + var path = disk.optString("path", ""); + if (change.from.path == null || !change.from.path.equals(path)) { + Log.w(TAG, fmt("Slot %d of %s moved since planning (%s); skipped", + slot, change.from.vmName, path)); + continue; + } + if (change.cleared()) { + disks.remove(slot); + } else { + disk.set("path", change.to.path); + if (change.to.readonly) disk.set("readonly", true); + } + changed = true; + } + return changed; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskOverlayCreateDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskOverlayCreateDialog.java new file mode 100644 index 00000000..129ea6e0 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskOverlayCreateDialog.java @@ -0,0 +1,312 @@ +// 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.ui.disk.action; + +import static android.widget.Toast.LENGTH_SHORT; +import static cn.classfun.droidvm.lib.utils.AssetUtils.getPrebuiltBinaryPath; +import static cn.classfun.droidvm.lib.utils.RunUtils.runList; +import static cn.classfun.droidvm.lib.utils.StringUtils.bulletList; +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.runOnPool; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.LayoutInflater; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.regex.Pattern; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.disk.DiskConfig; +import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.store.vm.VMStore; +import cn.classfun.droidvm.lib.utils.ImageUtils; +import cn.classfun.droidvm.ui.disk.create.DiskFormat; +import cn.classfun.droidvm.ui.disk.tree.AttachmentCursors; +import cn.classfun.droidvm.ui.disk.tree.AttachmentCursors.LiveRows; +import cn.classfun.droidvm.ui.disk.tree.CursorPlan; +import cn.classfun.droidvm.ui.disk.tree.DiskTree; +import cn.classfun.droidvm.ui.disk.tree.TreeShape; +import cn.classfun.droidvm.ui.vm.VmRunningQuery; + +/** + * Snapshot-feel overlay creation: one name field, instant {@code qemu-img create} (an overlay is + * just a header), the base becomes a locked parent. Every decision is collected BEFORE anything + * executes: the {@link CursorPlan} says which VM slots would follow the new overlay down (the + * base's writable attachments - "took a snapshot, keep going") and the user picks, per the + * whole batch, whether they do or flip to read-only instead; a base held writable by a VM that + * isn't stopped blocks creation outright, since its writes would shift the base underneath the + * overlay. After confirmation the create, registry link and VM updates run unattended. + */ +public final class DiskOverlayCreateDialog { + private static final String TAG = "DiskOverlayCreate"; + /** + * A name this dialog generated before, at the very end: {@code -ov-yyMMdd-HHmmss}, the older + * {@code -ov-yyyyMMdd-HHmm}, either with an optional {@code -N} collision bump. + */ + private static final Pattern OVERLAY_SUFFIX = + Pattern.compile("-ov-(\\d{8}-\\d{4}|\\d{6}-\\d{6})(-\\d+)?$"); + private final Handler mainLooper = new Handler(Looper.getMainLooper()); + private final Context context; + private final DiskConfig parent; + @Nullable + private final Runnable onUpdate; + @Nullable + private final Runnable onAdvanced; + @Nullable + private LiveRows live; + + public DiskOverlayCreateDialog( + @NonNull Context context, + @NonNull DiskConfig parent, + @Nullable Runnable onUpdate, + @Nullable Runnable onAdvanced + ) { + this.context = context; + this.parent = parent; + this.onUpdate = onUpdate; + this.onAdvanced = onAdvanced; + } + + /** + * The disk editor's unsaved rows when opened from one. Their cursors are not written here + * (the editor applies them when its panel closes) and the editor's own saved slots are + * rewritten without being announced - the rows on screen stand in for them. + */ + @NonNull + public DiskOverlayCreateDialog setLiveRows(@Nullable LiveRows live) { + this.live = live; + return this; + } + + public void show() { + var view = LayoutInflater.from(context) + .inflate(R.layout.dialog_overlay_create, null); + TextInputLayout layout = view.findViewById(R.id.til_overlay_name); + TextInputEditText etName = view.findViewById(R.id.et_overlay_name); + etName.setText(defaultName()); + var builder = new MaterialAlertDialogBuilder(context) + .setTitle(R.string.disk_create_increment) + .setMessage(context.getString( + R.string.disk_overlay_create_message, parent.getName())) + .setView(view) + .setPositiveButton(android.R.string.ok, null) + .setNegativeButton(android.R.string.cancel, null); + if (onAdvanced != null) + builder.setNeutralButton(R.string.disk_overlay_advanced, + (d, w) -> onAdvanced.run()); + var dialog = builder.create(); + dialog.show(); + dialog.getButton(android.content.DialogInterface.BUTTON_POSITIVE) + .setOnClickListener(v -> { + var text = etName.getText(); + var name = text != null ? text.toString().trim() : ""; + if (name.isEmpty()) { + layout.setError(context.getString(R.string.disk_create_error_name_invalid)); + return; + } + if (!name.toLowerCase(Locale.ROOT).endsWith(".qcow2")) name += ".qcow2"; + layout.setError(null); + dialog.dismiss(); + gather(name); + }); + } + + /** + * {@code -ov-}, to the second so two overlays taken in quick + * succession don't collide, and bumped with {@code -2, -3, ...} if a file of that name is + * already there. Stacking overlays replaces a trailing stamp instead of growing another one - + * a chain would otherwise read {@code disk-ov-260101-000000-ov-260102-000000-...}. + */ + @NonNull + private String defaultName() { + var base = parent.getName(); + int dot = base.lastIndexOf('.'); + if (dot > 0) base = base.substring(0, dot); + base = OVERLAY_SUFFIX.matcher(base).replaceFirst(""); + var stamp = new SimpleDateFormat("yyMMdd-HHmmss", Locale.ROOT).format(new Date()); + var folder = parent.item.optString("folder", ""); + var candidate = fmt("%s-ov-%s", base, stamp); + var name = candidate; + for (int n = 2; new File(pathJoin(folder, fmt("%s.qcow2", name))).exists(); n++) + name = fmt("%s-%d", candidate, n); + return name; + } + + // Phase 1, off the main thread: validate, plan where the base's attachments go. + private void gather(@NonNull String name) { + var folder = parent.item.optString("folder", ""); + var overlayPath = pathJoin(folder, name); + runOnPool(() -> { + try { + var store = new DiskStore(); + store.load(context); + if (store.findByName(name) != null || new File(overlayPath).exists()) { + fail(context.getString(R.string.disk_create_error_exists)); + return; + } + if (chainDepth(store, parent) + 1 >= DiskTree.MAX_DEPTH) { + fail(context.getString( + R.string.disk_overlay_depth_error, DiskTree.MAX_DEPTH)); + return; + } + var vmStore = new VMStore(); + vmStore.load(vmStore, context); + var shape = TreeShape.of(store); + var family = shape.familyOf(parent.getId()); + var inUse = VmRunningQuery.inUseAmong( + AttachmentCursors.allVmNames(vmStore, live == null ? null : live.vmName)); + var cursors = AttachmentCursors.collect(store, vmStore, family, live, inUse); + // The overlay's id isn't known yet; the plan only needs its path and parent. + var after = shape.withChild(UUID.randomUUID(), overlayPath, name, parent.getId()); + var plan = CursorPlan.reconcile(cursors, List.of(), shape, after); + if (plan.isRefused()) { + fail(context.getString(R.string.disk_overlay_vm_running, + bulletList(AttachmentCursors.vmNames(plan.refused)))); + return; + } + var announced = plan.announcedChanges(); + if (announced.isEmpty()) { + execute(name, overlayPath, plan, true); + return; + } + mainLooper.post(() -> askVmChoice(name, overlayPath, plan)); + } catch (Exception e) { + Log.w(TAG, "overlay pre-checks failed", e); + fail(String.valueOf(e.getMessage())); + } + }); + } + + // Phase 2, main thread: the one decision point - other VMs' writable attachments follow the + // overlay (default) or stay on the base read-only. Everything after runs unattended. + private void askVmChoice( + @NonNull String name, @NonNull String overlayPath, @NonNull CursorPlan plan) { + var lines = new ArrayList(); + for (var c : plan.announcedChanges()) + lines.add(fmt("%s (#%d)", c.from.vmName, c.from.slot + 1)); + new MaterialAlertDialogBuilder(context) + .setTitle(R.string.disk_overlay_vm_choice_title) + .setMessage(context.getString( + R.string.disk_overlay_vm_choice_message, parent.getName(), bulletList(lines))) + .setPositiveButton(R.string.disk_overlay_vm_switch, (d, w) -> + runOnPool(() -> execute(name, overlayPath, plan, true))) + .setNeutralButton(R.string.disk_overlay_vm_readonly, (d, w) -> + runOnPool(() -> execute(name, overlayPath, plan, false))) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + // Phase 3, off the main thread, no further interaction: create, register, update VMs. + private void execute( + @NonNull String name, + @NonNull String overlayPath, + @NonNull CursorPlan plan, + boolean switchVmsToOverlay + ) { + try { + var parentPath = parent.getFullPath(); + var backingFormat = detectFormat(parentPath); + var result = runList( + getPrebuiltBinaryPath("qemu-img"), "create", + "--format", "qcow2", + "--backing", parentPath, + "--backing-format", backingFormat, + overlayPath + ); + if (!result.isSuccess()) { + result.printLog(TAG); + fail(result.getErrString()); + return; + } + var store = new DiskStore(); + store.load(context); + var overlay = new DiskConfig(); + overlay.setName(name); + overlay.item.set("folder", parent.item.optString("folder", "")); + overlay.setParentId(parent.getId()); + store.add(overlay); + store.save(context); + if (!DiskDependencyUpdater.applyPlan(context, vmPlan(plan, switchVmsToOverlay))) + Log.e(TAG, "overlay created but VM attachments could not be updated"); + mainLooper.post(() -> { + Toast.makeText(context, + context.getString(R.string.disk_overlay_created, name), + LENGTH_SHORT).show(); + if (onUpdate != null) onUpdate.run(); + }); + } catch (Exception e) { + Log.e(TAG, "overlay creation failed", e); + fail(String.valueOf(e.getMessage())); + } + } + + /** + * The plan as the user chose it: with "make read-only", the announced slots stay on the base + * read-only instead of following the overlay. The editor's own saved slots (shadows) always + * follow - the rows on screen do, and a discarded edit must leave them consistent with what + * the user saw. + */ + @NonNull + private static CursorPlan vmPlan(@NonNull CursorPlan plan, boolean switchVmsToOverlay) { + if (switchVmsToOverlay) return plan; + var alt = CursorPlan.reconcile(List.of(), List.of(), TreeShape.empty(), TreeShape.empty()); + for (var c : plan.changes) { + if (c.from.isAnnounced() && c.moved()) + alt.changes.add(new CursorPlan.Change( + c.from, c.from.at(c.from.nodeId, c.from.path, true))); + else + alt.changes.add(c); + } + return alt; + } + + private static int chainDepth(@NonNull DiskStore store, @NonNull DiskConfig config) { + int depth = 0; + var visited = new HashSet(); + var current = config; + while (current != null && visited.add(current.getId())) { + depth++; + current = store.parentOf(current); + } + return depth; + } + + @NonNull + private static String detectFormat(@NonNull String path) { + try { + var info = ImageUtils.getImageInfo(path); + var f = info.optString("format", ""); + if (!f.isEmpty()) return f; + } catch (Exception ignored) { + } + return DiskFormat.fromFilename(path).name().toLowerCase(Locale.ROOT); + } + + private void fail(@Nullable String message) { + mainLooper.post(() -> new MaterialAlertDialogBuilder(context) + .setTitle(R.string.disk_create_increment) + .setMessage(message == null ? "?" : message) + .setPositiveButton(android.R.string.ok, null) + .show()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskResizeDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskResizeDialog.java index d4a2b9c8..6432d261 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskResizeDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskResizeDialog.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.ui.disk.action; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; @@ -5,6 +8,7 @@ import android.content.Context; import android.content.DialogInterface; +import android.content.Intent; import android.os.Handler; import android.os.Looper; import android.util.Log; @@ -13,14 +17,20 @@ import androidx.annotation.NonNull; +import com.google.android.material.checkbox.MaterialCheckBox; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.json.JSONObject; import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.vm.VMBackend; +import cn.classfun.droidvm.lib.store.vm.VMHypervisor; import cn.classfun.droidvm.lib.utils.ImageUtils; import cn.classfun.droidvm.lib.store.disk.DiskConfig; import cn.classfun.droidvm.lib.size.SizeUnit; +import cn.classfun.droidvm.ui.agent.AgentOperationActivity; +import cn.classfun.droidvm.ui.agent.autogrow.AutoGrowAction; +import cn.classfun.droidvm.ui.agent.base.AgentVM; import cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity; import cn.classfun.droidvm.ui.widgets.row.TextInputRowWidget; @@ -34,6 +44,7 @@ public final class DiskResizeDialog { private final Handler handler; private final TextView tvFilename; private final TextView tvFolder; + private final MaterialCheckBox cbAutoGrow; public DiskResizeDialog(@NonNull Context context, @NonNull DiskConfig config) { this.context = context; @@ -43,6 +54,7 @@ public DiskResizeDialog(@NonNull Context context, @NonNull DiskConfig config) { tvCurrent = view.findViewById(R.id.tv_current_size); tvFilename = view.findViewById(R.id.tv_filename); tvFolder = view.findViewById(R.id.tv_folder); + cbAutoGrow = view.findViewById(R.id.cb_autogrow); currentVirtualSize = -1; inputSize.setValue(1, SizeUnit.GB); tvCurrent.setText(R.string.disk_resize_loading); @@ -52,10 +64,13 @@ public DiskResizeDialog(@NonNull Context context, @NonNull DiskConfig config) { var dialog = new MaterialAlertDialogBuilder(context) .setTitle(R.string.disk_resize_title) .setView(view) - .setPositiveButton(android.R.string.ok, this::dialogOkOnClick) + .setPositiveButton(android.R.string.ok, null) .setNegativeButton(android.R.string.cancel, null) .create(); dialog.show(); + // Override the positive listener so validation can keep the resize dialog open. + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener(v -> + dialogOkOnClick(dialog, DialogInterface.BUTTON_POSITIVE)); runOnPool(this::asyncOperation); } @@ -100,36 +115,65 @@ private void dialogOkOnClick(DialogInterface dialog, int which) { } long newBytes = inputSize.getValue(); inputSize.setError(null); - boolean isShrink = currentVirtualSize > 0 && newBytes <= currentVirtualSize; + cbAutoGrow.setError(null); + boolean isShrink = currentVirtualSize > 0 && newBytes < currentVirtualSize; + boolean isSameSize = currentVirtualSize > 0 && newBytes == currentVirtualSize; + boolean autoGrow = cbAutoGrow.isChecked(); + if (isShrink && autoGrow) { + cbAutoGrow.setError(context.getString( + R.string.disk_resize_error_autogrow_shrink)); + return; + } + if (isSameSize) { + if (!autoGrow) { + inputSize.setError(context.getString(R.string.disk_resize_error_no_action)); + return; + } + dialog.dismiss(); + context.startActivity(createAutoGrowIntent()); + return; + } if (isShrink) { new MaterialAlertDialogBuilder(context) .setTitle(R.string.disk_resize_shrink_title) .setMessage(R.string.disk_resize_shrink_message) .setPositiveButton(android.R.string.ok, (d, w) -> { dialog.dismiss(); - doResize(newBytes, true); + doResize(newBytes, true, false); }) .setNegativeButton(android.R.string.cancel, null) .show(); } else { dialog.dismiss(); - doResize(newBytes, false); + doResize(newBytes, false, autoGrow); } } catch (Exception e) { inputSize.setError(context.getString(R.string.disk_resize_error_invalid)); } } - private void doResize(long bytes, boolean shrink) { + private void doResize(long bytes, boolean shrink, boolean autoGrow) { try { var obj = new JSONObject(); obj.put("action", "resize"); obj.put("size", String.valueOf(bytes)); if (shrink) obj.put("shrink", true); var intent = DiskOperationActivity.createIntent(context, config.getId(), obj); + if (autoGrow) + intent.putExtra(DiskOperationActivity.EXTRA_SUCCESS_INTENT, + createAutoGrowIntent()); context.startActivity(intent); } catch (Exception e) { Log.e(TAG, "Failed to start resize activity", e); } } + + @NonNull + private Intent createAutoGrowIntent() { + var agentVM = new AgentVM(VMBackend.QEMU, VMHypervisor.SOFT); + agentVM.setOperationConsole("uart", "/dev/ttyAMA0"); + new AutoGrowAction(agentVM); + agentVM.addDisk(config); + return AgentOperationActivity.createIntent(context, agentVM); + } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskSetFormatDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskSetFormatDialog.java index 358f4869..98688864 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskSetFormatDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/action/DiskSetFormatDialog.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.ui.disk.action; import static android.widget.Toast.LENGTH_LONG; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskCompress.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskCompress.java index 855fb110..10564abe 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskCompress.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskCompress.java @@ -1,15 +1,34 @@ +// 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.ui.disk.create; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; +import java.util.Collections; +import java.util.EnumSet; +import java.util.Locale; +import java.util.Set; + import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.store.enums.StringEnum; +import cn.classfun.droidvm.lib.utils.ImageUtils; public enum DiskCompress implements StringEnum { NONE(R.string.disk_create_compress_disabled), DEFLATE(R.string.disk_create_compress_deflate), ZSTD(R.string.disk_create_compress_zstd); + /** + * qcow2 compression the crosvm backend can read. Currently only uncompressed images boot; + * when crosvm grows zlib (and later zstd) support, extend this set and every consumer - + * the post-import optimize skip and the pre-start convert prompt - follows. + */ + public static final Set CROSVM_SUPPORTED = + Collections.unmodifiableSet(EnumSet.of(NONE)); + private final @StringRes int stringId; DiskCompress(int stringId) { @@ -21,4 +40,40 @@ public enum DiskCompress implements StringEnum { public int getStringId() { return stringId; } + + public boolean isCrosvmSupported() { + return CROSVM_SUPPORTED.contains(this); + } + + /** The wire value used in disk-operation tasks and preferences: none / deflate / zstd. */ + @NonNull + public String value() { + return name().toLowerCase(Locale.ROOT); + } + + @Nullable + public static DiskCompress fromValue(@Nullable String value) { + if (value == null) return null; + for (var c : values()) { + if (c.value().equals(value)) return c; + } + return null; + } + + /** Maps qemu's compression naming ({@code none}/{@code zlib}/{@code zstd}). */ + @NonNull + public static DiskCompress fromQemuType(@Nullable String type) { + if ("zstd".equals(type)) return ZSTD; + if ("zlib".equals(type)) return DEFLATE; + return NONE; + } + + /** + * The image's effective compression, via qemu-img ({@link ImageUtils#detectCompression}): + * NONE unless it really stores compressed clusters. Blocking - call off the main thread. + */ + @NonNull + public static DiskCompress detect(@NonNull String path) { + return fromQemuType(ImageUtils.detectCompression(path)); + } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskCreateActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskCreateActivity.java index b372f6bc..241ffc9c 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskCreateActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskCreateActivity.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.ui.disk.create; import static android.view.View.GONE; @@ -269,9 +272,18 @@ private void doCreate() { inputName.setError(R.string.disk_create_error_exists); return; } + // Link the overlay to its registered backing so the tree and lock rules see it. A + // manually-typed backing path counts too when it resolves to a registered disk. + var manualBacking = backing; runOnPool(() -> { var store = new DiskStore(); store.load(this); + if (backingId != null) { + config.setParentId(backingId); + } else if (!manualBacking.isEmpty()) { + var registered = store.findByPath(manualBacking); + if (registered != null) config.setParentId(registered.getId()); + } store.add(config); store.save(this); }); @@ -291,6 +303,10 @@ private void doCreate() { } else if (!backing.isEmpty() && format == DiskFormat.QCOW2) obj.put("backing_path", backing); var intent = createIntent(this, config.getId(), obj); + // A create that worked needs no success screen: the new disk showing up (in the + // list, or in the VM row that asked for it) is the feedback. Failures still stay. + intent.putExtra(cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity + .EXTRA_AUTOFINISH, true); startActivity(intent); } catch (Exception e) { Log.e(TAG, "Failed to start create activity", e); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskFormat.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskFormat.java index 7d1945d7..7a45f26e 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskFormat.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/create/DiskFormat.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.ui.disk.create; import static cn.classfun.droidvm.lib.utils.StringUtils.extensionLower; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/download/ImportURLActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/download/ImportURLActivity.java index a6cacee1..4b009745 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/download/ImportURLActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/download/ImportURLActivity.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.ui.disk.download; import static android.view.View.GONE; @@ -11,7 +14,7 @@ import static cn.classfun.droidvm.lib.utils.StringUtils.resolveUriPath; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; import static cn.classfun.droidvm.lib.size.SizeUtils.formatSize; -import static cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity.startOptimize; +import static cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity.startOptimizeAfterImport; import android.content.Intent; import android.net.Uri; @@ -49,6 +52,7 @@ import cn.classfun.droidvm.lib.ui.NotificationPermission; import cn.classfun.droidvm.lib.ui.SimpleTextWatcher; import cn.classfun.droidvm.lib.utils.NetUtils.HttpException; +import cn.classfun.droidvm.ui.disk.action.BackingChainLinker; import cn.classfun.droidvm.ui.disk.create.DiskFormat; import cn.classfun.droidvm.ui.widgets.tools.DownloadWidget; import cn.classfun.droidvm.ui.widgets.tools.KernelAnalysisWidget; @@ -435,8 +439,15 @@ private void onDownloadSucceeded() { var resultData = new Intent(); resultData.putExtra("result_disk_path", pathJoin(result.folder, result.name)); setResult(RESULT_OK, resultData); - if (result.diskId != null && DiskFormat.fromFilename(result.name) == DiskFormat.QCOW2) - startOptimize(this, result.diskId); + if (result.diskId != null && DiskFormat.fromFilename(result.name) == DiskFormat.QCOW2) { + // Resolve the backing chain first (may prompt once), then rewrite only when the + // compression can't boot on crosvm; finish once everything is decided. + var diskId = result.diskId; + var diskPath = pathJoin(result.folder, result.name); + BackingChainLinker.link(this, diskId, () -> + startOptimizeAfterImport(this, diskId, diskPath, this::finish)); + return; + } finish(); } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/images/FlatImage.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/images/FlatImage.java index cbf01d2a..ff56efea 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/images/FlatImage.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/images/FlatImage.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.ui.disk.images; import static cn.classfun.droidvm.lib.size.SizeUtils.formatSize; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImageAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImageAdapter.java index c6ab065e..cf1ac9f1 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImageAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImageAdapter.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.ui.disk.images; import android.annotation.SuppressLint; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImagePickerDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImagePickerDialog.java index c2de9286..7285257c 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImagePickerDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImagePickerDialog.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.ui.disk.images; import static android.view.View.GONE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImageViewHolder.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImageViewHolder.java index e975d006..5720ddee 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImageViewHolder.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImageViewHolder.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.ui.disk.images; import android.view.View; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImportImagesActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImportImagesActivity.java index 389b809e..98348c8d 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImportImagesActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/images/ImportImagesActivity.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.ui.disk.images; import static android.view.View.GONE; @@ -9,7 +12,7 @@ import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import static cn.classfun.droidvm.lib.utils.StringUtils.resolveUriPath; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; -import static cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity.startOptimize; +import static cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity.startOptimizeAfterImport; import android.content.Intent; import android.net.Uri; @@ -42,6 +45,7 @@ import cn.classfun.droidvm.lib.download.DiskDownloadService; import cn.classfun.droidvm.lib.ui.IconItemAdapter; import cn.classfun.droidvm.lib.ui.NotificationPermission; +import cn.classfun.droidvm.ui.disk.action.BackingChainLinker; import cn.classfun.droidvm.ui.disk.create.DiskFormat; import cn.classfun.droidvm.ui.widgets.row.DropdownRowWidget; import cn.classfun.droidvm.ui.widgets.row.TextInputRowWidget; @@ -394,8 +398,15 @@ private void onDownloadSucceeded() { var resultData = new Intent(); resultData.putExtra("result_disk_path", pathJoin(result.folder, result.name)); setResult(RESULT_OK, resultData); - if (result.diskId != null && DiskFormat.fromFilename(result.name) == DiskFormat.QCOW2) - startOptimize(this, result.diskId); + if (result.diskId != null && DiskFormat.fromFilename(result.name) == DiskFormat.QCOW2) { + // Resolve the backing chain first (may prompt once), then rewrite only when the + // compression can't boot on crosvm; finish once everything is decided. + var diskId = result.diskId; + var diskPath = pathJoin(result.folder, result.name); + BackingChainLinker.link(this, diskId, () -> + startOptimizeAfterImport(this, diskId, diskPath, this::finish)); + return; + } finish(); } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/DiskInfoActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/DiskInfoActivity.java index d4890921..7e4b17f8 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/DiskInfoActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/DiskInfoActivity.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.ui.disk.info; import static cn.classfun.droidvm.lib.utils.AssetUtils.getPrebuiltBinaryPath; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/base/DiskInfoBaseTab.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/base/DiskInfoBaseTab.java index 3df8212d..2b7e27fe 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/base/DiskInfoBaseTab.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/base/DiskInfoBaseTab.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.ui.disk.info.base; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/base/DiskInfoTab.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/base/DiskInfoTab.java index 19997997..3c65236c 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/base/DiskInfoTab.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/base/DiskInfoTab.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.ui.disk.info.base; import android.widget.FrameLayout; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/info/DiskInfoInfoTab.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/info/DiskInfoInfoTab.java index f819a584..96a58db6 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/info/DiskInfoInfoTab.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/info/DiskInfoInfoTab.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.ui.disk.info.info; import static android.view.View.GONE; @@ -9,10 +12,14 @@ import android.content.Context; import android.content.DialogInterface; import android.util.Log; +import android.view.LayoutInflater; +import android.view.MenuItem; import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.PopupMenu; +import android.widget.Space; import android.widget.TextView; -import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -21,12 +28,15 @@ import org.json.JSONObject; +import java.util.ArrayList; + import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.store.disk.DiskConfig; import cn.classfun.droidvm.lib.store.disk.DiskStore; import cn.classfun.droidvm.ui.disk.action.DiskActionDialog; import cn.classfun.droidvm.ui.disk.info.DiskInfoActivity; import cn.classfun.droidvm.ui.disk.info.base.DiskInfoBaseTab; +import cn.classfun.droidvm.ui.disk.tree.DiskBranchPanel; import cn.classfun.droidvm.ui.widgets.container.CollapsibleContainer; import cn.classfun.droidvm.ui.widgets.row.TextRowWidget; @@ -47,12 +57,8 @@ public final class DiskInfoInfoTab extends DiskInfoBaseTab { private TextRowWidget rowEncryption; private TextRowWidget rowCompression; private TextRowWidget rowDirtyFlag; - private MaterialButton btnResize; - private MaterialButton btnConvert; - private MaterialButton btnOptimize; - private MaterialButton btnCreateIncrement; - private MaterialButton btnClone; - private MaterialButton btnDelete; + private LinearLayout actionsContainer; + private int actionsMenuResId; private DiskActionDialog dialog; public DiskInfoInfoTab( @@ -84,23 +90,13 @@ public void onCreateView() { rowDirtyFlag = view.findViewById(R.id.row_dirty_flag); sectionRaw = view.findViewById(R.id.section_raw); tvRawOutput = view.findViewById(R.id.tv_raw_output); - btnResize = view.findViewById(R.id.btn_resize); - btnConvert = view.findViewById(R.id.btn_convert); - btnOptimize = view.findViewById(R.id.btn_optimize); - btnCreateIncrement = view.findViewById(R.id.btn_create_increment); - btnClone = view.findViewById(R.id.btn_clone); - btnDelete = view.findViewById(R.id.btn_delete); + actionsContainer = view.findViewById(R.id.disk_actions_container); initialize(); } private void initialize() { dialog = new DiskActionDialog(activity, this::onDiskUpdated, null); - bindButton(btnResize, R.id.menu_disk_resize); - bindButton(btnConvert, R.id.menu_disk_convert); - bindButton(btnOptimize, R.id.menu_disk_optimize); - bindButton(btnCreateIncrement, R.id.menu_disk_create_increment); - bindButton(btnClone, R.id.menu_disk_clone); - bindButton(btnDelete, R.id.menu_disk_delete); + populateActions(activity.config); bindCopy(rowFilename); bindCopy(rowFolder); bindCopy(rowFormat); @@ -111,15 +107,83 @@ private void initialize() { bindCopy(rowEncryption); bindCopy(rowCompression); bindCopy(rowDirtyFlag); - var rowExtra1 = view.findViewById(R.id.row_extra_1); - var rowExtra2 = view.findViewById(R.id.row_extra_2); - var fmt = activity.config.getFormat(); - if (!DiskConfig.supportsExtraOperations(fmt)) { - rowExtra1.setVisibility(GONE); - rowExtra2.setVisibility(GONE); + } + + /** + * Build from the list long-press menu, folding create/merge/flatten into the existing branch + * manager because the tree dialog owns those relationship-changing operations here. + */ + private void populateActions(@Nullable DiskConfig config) { + if (config == null) return; + int menuResId = DiskActionDialog.getMenuResId(config); + if (menuResId == actionsMenuResId && actionsContainer.getChildCount() > 0) return; + actionsMenuResId = menuResId; + actionsContainer.removeAllViews(); + + var menuHost = new PopupMenu(activity, actionsContainer); + menuHost.getMenuInflater().inflate(menuResId, menuHost.getMenu()); + var actions = new ArrayList(); + boolean branchesAdded = false; + for (int i = 0; i < menuHost.getMenu().size(); i++) { + var item = menuHost.getMenu().getItem(i); + if (!item.isVisible()) continue; + if (isBranchAction(item.getItemId())) { + if (!branchesAdded) { + actions.add(null); // null is the synthetic "Manage overlays" action. + branchesAdded = true; + } + } else { + actions.add(item); + } + } + + LinearLayout row = null; + int visibleCount = 0; + for (var item : actions) { + if ((visibleCount & 1) == 0) { + row = new LinearLayout(activity); + row.setOrientation(LinearLayout.HORIZONTAL); + actionsContainer.addView(row, new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT)); + } + var button = (MaterialButton) LayoutInflater.from(activity) + .inflate(R.layout.item_disk_info_action, row, false); + if (item == null) { + button.setText(R.string.disk_manage_branches); + button.setIconResource(R.drawable.ic_file_multiple); + button.setOnClickListener(v -> DiskBranchPanel.open( + activity, activity.config.getFullPath(), null, + // The panel stays up until Close, even after its own disk is deleted (it + // shows an empty tree then); only closing it takes this page down with it. + result -> { + if (result.subjectGone) activity.finish(); + else onDiskUpdated(); + })); + } else { + button.setText(item.getTitle()); + button.setIcon(item.getIcon()); + int actionId = item.getItemId(); + button.setOnClickListener(v -> + dialog.diskMenuOnClick(activity.config, actionId)); + } + row.addView(button); + visibleCount++; + } + if ((visibleCount & 1) != 0 && row != null) { + var spacer = new Space(activity); + row.addView(spacer, new LinearLayout.LayoutParams( + 0, 0, 1)); } } + private static boolean isBranchAction(int id) { + return id == R.id.menu_disk_create_increment + || id == R.id.menu_disk_merge + || id == R.id.menu_disk_flatten + || id == R.id.menu_disk_reset; + } + private void onDiskUpdated() { try { var store = new DiskStore(); @@ -131,10 +195,6 @@ private void onDiskUpdated() { } } - private void bindButton(@NonNull MaterialButton btn, @IdRes int id) { - btn.setOnClickListener(v -> dialog.diskMenuOnClick(activity.config, id)); - } - private void bindCopy(@NonNull TextRowWidget tr) { tr.setOnClickListener(v -> showCopyDialog(tr.getTitle(), tr.getValue())); } @@ -143,6 +203,7 @@ private void bindCopy(@NonNull TextRowWidget tr) { public void onConfigLoaded() { var config = activity.config; if (config == null) return; + populateActions(config); var name = config.getName(); var folder = config.item.optString("folder", ""); var format = config.getFormat(); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskInfoSnapshotTab.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskInfoSnapshotTab.java index 465057d9..19cc259b 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskInfoSnapshotTab.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskInfoSnapshotTab.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.ui.disk.info.snapshot; import static android.content.DialogInterface.BUTTON_POSITIVE; @@ -8,6 +11,7 @@ import static java.text.DateFormat.SHORT; import static java.text.DateFormat.getDateTimeInstance; import static cn.classfun.droidvm.lib.size.SizeUtils.formatSize; +import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum; import static cn.classfun.droidvm.lib.utils.AssetUtils.getPrebuiltBinaryPath; import static cn.classfun.droidvm.lib.utils.RunUtils.runList; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -36,7 +40,11 @@ import java.util.Date; import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.lib.store.disk.DiskConfig; +import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.store.vm.VMBackend; +import cn.classfun.droidvm.lib.store.vm.VMStore; import cn.classfun.droidvm.lib.ui.MaterialMenu; import cn.classfun.droidvm.ui.disk.info.DiskInfoActivity; import cn.classfun.droidvm.ui.disk.info.base.DiskInfoBaseTab; @@ -194,7 +202,58 @@ private void showCreateDialog() { } inputLayout.setError(null); dialog.dismiss(); - runSnapshotCommand("-c", name); + warnIfUsedByCrosvm(() -> runSnapshotCommand("-c", name)); + }); + } + + /** + * crosvm has no qcow2 snapshot support and refuses to open a snapshotted disk for writing, + * so a snapshot silently makes any crosvm VM holding this disk writable un-startable (until + * the pre-start prompt flattens it away again). Say so before creating one; QEMU-only and + * read-only users are unaffected and see no dialog. The store scan is off the main thread. + */ + private void warnIfUsedByCrosvm(@NonNull Runnable proceed) { + var config = activity.config; + if (config == null) return; + var fullPath = config.getFullPath(); + runOnPool(() -> { + String vmName = null; + try { + var store = new VMStore(); + store.load(activity); + for (int i = 0; i < store.size() && vmName == null; i++) { + var vm = store.get(i); + if (optEnum(vm.item, "backend", VMBackend.DEFAULT) != VMBackend.CROSVM) + continue; + var disks = vm.item.opt("disks", null); + if (disks == null || !disks.is(DataItem.Type.ARRAY)) continue; + for (var disk : disks.asArray()) { + if (fullPath.equals(disk.optString("path", "")) + && !disk.optBoolean("readonly", false)) { + vmName = vm.getName(); + break; + } + } + } + } catch (Exception e) { + Log.w(TAG, "Failed to scan VMs for this disk", e); + } + final var attachedTo = vmName; + activity.runOnUiThread(() -> { + if (activity.isFinishing()) return; + if (attachedTo == null) { + proceed.run(); + return; + } + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.disk_snapshot_crosvm_warning_title) + .setMessage(activity.getString( + R.string.disk_snapshot_crosvm_warning_message, attachedTo)) + .setPositiveButton(R.string.disk_snapshot_crosvm_warning_create, + (d, w) -> proceed.run()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + }); }); } @@ -243,6 +302,30 @@ private void runSnapshotCommand(@NonNull String flag, @NonNull String snapshotNa if (config == null) return; var fullPath = config.getFullPath(); runOnPool(() -> { + // Snapshot commands rewrite the image; a disk other images overlay is locked. + try { + var diskStore = new DiskStore(); + if (!diskStore.load(activity)) { + activity.runOnUiThread(() -> Toast.makeText( + activity, R.string.disk_info_load_failed, LENGTH_SHORT).show()); + return; + } + int n = diskStore.childrenOf(config.getId()).size(); + if (n > 0) { + activity.runOnUiThread(() -> { + if (activity.isFinishing()) return; + new MaterialAlertDialogBuilder(activity) + .setTitle(R.string.disk_locked_title) + .setMessage(activity.getString( + R.string.disk_locked_message, config.getName(), n)) + .setPositiveButton(android.R.string.ok, null) + .show(); + }); + return; + } + } catch (Exception e) { + Log.w(TAG, "Failed to check disk children", e); + } try { var result = runList( getPrebuiltBinaryPath("qemu-img"), @@ -280,4 +363,3 @@ private void showErrorDialog(@NonNull String message) { if (tvLog != null) tvLog.append(message.trim()); } } - diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskSnapshotEntryAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskSnapshotEntryAdapter.java index 0066b2da..2499caff 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskSnapshotEntryAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskSnapshotEntryAdapter.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.ui.disk.info.snapshot; import static android.view.View.GONE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskSnapshotEntryViewHolder.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskSnapshotEntryViewHolder.java index fc492c61..63e16073 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskSnapshotEntryViewHolder.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/snapshot/DiskSnapshotEntryViewHolder.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.ui.disk.info.snapshot; import android.view.View; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskInfoTreeTab.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskInfoTreeTab.java index 39e3dce0..08bf4004 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskInfoTreeTab.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskInfoTreeTab.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.ui.disk.info.tree; import static android.view.View.GONE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskTreeEntryAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskTreeEntryAdapter.java index 45c1f8d6..f78a2c7d 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskTreeEntryAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskTreeEntryAdapter.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.ui.disk.info.tree; import static android.view.View.GONE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskTreeEntryViewHolder.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskTreeEntryViewHolder.java index 8a62760e..0f341cbd 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskTreeEntryViewHolder.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/info/tree/DiskTreeEntryViewHolder.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.ui.disk.info.tree; import android.view.View; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/CreateLinuxVmActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/CreateLinuxVmActivity.java new file mode 100644 index 00000000..53204712 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/CreateLinuxVmActivity.java @@ -0,0 +1,12 @@ +// 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.ui.disk.lxc; + +/** Linux-VM entry point backed by the shared LXC image catalogue/download flow. */ +public final class CreateLinuxVmActivity extends ImportLxcImagesActivity { + @Override + protected boolean isLinuxVmMode() { + return true; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/ImportLxcImagesActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/ImportLxcImagesActivity.java index ebe80ea9..e559f17d 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/ImportLxcImagesActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/ImportLxcImagesActivity.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.ui.disk.lxc; import static android.R.attr.colorError; @@ -7,21 +10,31 @@ import static com.google.android.material.R.attr.colorOnSurfaceVariant; import static java.util.Objects.requireNonNullElse; import static cn.classfun.droidvm.lib.size.SizeUtils.formatSize; +import static cn.classfun.droidvm.lib.utils.FileUtils.checkFileName; import static cn.classfun.droidvm.lib.utils.FileUtils.externalPath; import static cn.classfun.droidvm.lib.utils.NetUtils.BROWSER_USER_AGENT; import static cn.classfun.droidvm.lib.utils.NetUtils.LXC_USER_AGENT; import static cn.classfun.droidvm.lib.utils.NetUtils.fetchJSON; +import static cn.classfun.droidvm.lib.utils.NetUtils.generateRandomMac; +import static cn.classfun.droidvm.lib.utils.StringUtils.SHELL_SAFE_PASSWORD_SYMBOLS; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.generateGroupedPassword; +import static cn.classfun.droidvm.lib.utils.StringUtils.isShellSafePassword; import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import static cn.classfun.droidvm.lib.utils.StringUtils.resolveUriPath; +import static cn.classfun.droidvm.lib.utils.StringUtils.shellSafePasswordFilter; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; -import static cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity.startOptimize; +import static cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity.startOptimizeAfterImport; +import static cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity.startOptimizeAfterImportForResult; +import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.text.Editable; import android.util.Log; import android.view.View; import android.widget.TextView; @@ -42,6 +55,8 @@ import com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton; import com.google.android.material.progressindicator.CircularProgressIndicator; +import org.json.JSONObject; + import java.io.File; import java.util.ArrayList; import java.util.Comparator; @@ -50,6 +65,11 @@ import java.util.Map; import java.util.TreeMap; import java.util.TreeSet; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.api.ApiManager; @@ -57,19 +77,58 @@ import cn.classfun.droidvm.lib.data.Repos; import cn.classfun.droidvm.lib.download.DiskDownloadManager; import cn.classfun.droidvm.lib.download.DiskDownloadService; +import cn.classfun.droidvm.lib.size.SizeUnit; +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.disk.DiskBus; +import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.store.network.NetworkStore; +import cn.classfun.droidvm.lib.store.vm.VMConfig; +import cn.classfun.droidvm.lib.store.vm.VMBackend; +import cn.classfun.droidvm.lib.store.vm.VMHypervisor; +import cn.classfun.droidvm.lib.store.vm.VMStore; +import cn.classfun.droidvm.lib.ui.CopyableField; import cn.classfun.droidvm.lib.ui.IconItemAdapter; import cn.classfun.droidvm.lib.ui.NotificationPermission; +import cn.classfun.droidvm.lib.ui.SimpleTextWatcher; +import cn.classfun.droidvm.ui.agent.AgentOperationActivity; +import cn.classfun.droidvm.ui.agent.autogrow.AutoGrowAction; +import cn.classfun.droidvm.ui.agent.base.AgentVM; +import cn.classfun.droidvm.ui.agent.password.ChangePasswordActivity; +import cn.classfun.droidvm.ui.agent.password.PasswordAction; +import cn.classfun.droidvm.ui.disk.action.BackingChainLinker; import cn.classfun.droidvm.ui.disk.create.DiskFormat; import cn.classfun.droidvm.ui.widgets.row.DropdownRowWidget; import cn.classfun.droidvm.ui.widgets.row.TextInputRowWidget; import cn.classfun.droidvm.ui.widgets.tools.DownloadWidget; import cn.classfun.droidvm.ui.widgets.tools.KernelAnalysisWidget; -public final class ImportLxcImagesActivity extends AppCompatActivity { +public class ImportLxcImagesActivity extends AppCompatActivity { private static final String TAG = "ImportLxcImages"; private static final String IMAGES_META_PATH = "/streams/v1/images.json"; private static final long POLL_INTERVAL_MS = 500; + private static final String PREFS_NAME = "droidvm_prefs"; + private static final String PREF_META_SOURCE = "lxc_meta_source"; + private static final String PREF_DL_SOURCE = "lxc_download_source"; + private static final String PREF_DL_SOURCE_NAME = "lxc_download_source_name"; + private static final String PREF_DL_SOURCE_URL = "lxc_download_source_url"; + private static final String PREF_CUSTOM_META_URL = "lxc_custom_meta_url"; + private static final String PREF_CUSTOM_DL_URL = "lxc_custom_download_url"; + private static final String SOURCE_OFFICIAL = "official"; + private static final String SOURCE_CLASSFUN = "classfun"; + private static final String SOURCE_CERNET = "cernet"; + private static final String SOURCE_CUSTOM = "custom"; + private static final String STATE_PENDING_PASSWORD_DISK_ID = "pending_password_disk_id"; + private static final String STATE_PENDING_RESET_PASSWORD = "pending_reset_password"; + private static final String STATE_PENDING_IMPORT_NAME = "pending_import_name"; + private static final String STATE_PENDING_LINUX_DISK_ID = "pending_linux_disk_id"; + private static final String STATE_PENDING_LINUX_VM_NAME = "pending_linux_vm_name"; + private static final String STATE_PENDING_LINUX_ROOT_PASSWORD = "pending_linux_root_password"; + private static final String STATE_PENDING_LINUX_NETWORK_ID = "pending_linux_network_id"; + private static final String STATE_PENDING_LINUX_CPU = "pending_linux_cpu"; + private static final String STATE_PENDING_LINUX_MEMORY_MB = "pending_linux_memory_mb"; + private static final String STATE_PENDING_LINUX_DISK_BYTES = "pending_linux_disk_bytes"; private final Map displayVersionToRelease = new LinkedHashMap<>(); + private Repos.Repo builtinLxcRepo; private Repos.Repo lxcRepo; private String[] metaSourceKeys; private String[] metaSourceLabels; @@ -77,6 +136,7 @@ public final class ImportLxcImagesActivity extends AppCompatActivity { private TextInputRowWidget inputCustomMetaUrl; private String[] dlSourceKeys; private String[] dlSourceLabels; + private String[] dlSourceUrls; private DropdownRowWidget dropdownDlSource; private TextInputRowWidget inputCustomDlUrl; private TextView tvMetaStatus; @@ -85,7 +145,14 @@ public final class ImportLxcImagesActivity extends AppCompatActivity { private View dividerImage, tvImageHeader; private DropdownRowWidget dropdownDistro, dropdownVersion, dropdownVariant, dropdownBuild; private View dividerOutput, tvOutputHeader; - private TextInputRowWidget inputFilename, inputFolder; + private TextInputRowWidget inputFilename, inputFolder, inputResetPassword; + private View dividerSettings, tvSettingsHeader; + private TextInputRowWidget inputVmName, inputVmCpu, inputVmMemory; + private TextInputRowWidget inputVmDiskSize, inputVmRootPassword; + private DropdownRowWidget dropdownVmNetwork; + private String[] vmNetworkIds = new String[0]; + private String[] vmNetworkLabels = new String[0]; + private String selectedVmNetworkId = ""; private MaterialCardView cardInfo; private TextView tvInfoSize, tvInfoPath; private ExtendedFloatingActionButton fabImport; @@ -99,20 +166,57 @@ public final class ImportLxcImagesActivity extends AppCompatActivity { private LxcImage selectedImage; private boolean isLoading = false; private boolean isDownloading = false; - private boolean isClassFunApiAvailable = false; - /** True once the user explicitly picks a source, so a background refresh - * won't override their choice (but may still upgrade an untouched default). */ - private boolean userTouchedSource = false; + private boolean isProbingSources = false; + private volatile boolean isClassFunApiAvailable = false; + private String selectedMetaSourceKey = SOURCE_OFFICIAL; + private String selectedDlSourceKey = SOURCE_OFFICIAL; + private String rememberedDlSourceKey = ""; + private String rememberedDlSourceName = ""; + private String rememberedDlSourceUrl = ""; + private boolean automaticMetadataRequest = false; + private SharedPreferences sourcePrefs; + /** Only the first valid response may settle the one-time source probe. */ + private final AtomicBoolean sourceProbeSettled = new AtomicBoolean(false); + /** A manual selection always wins, including against an already queued probe callback. */ + private final AtomicBoolean userOverrodeSourceProbe = new AtomicBoolean(false); + private final AtomicInteger sourceProbeFailures = new AtomicInteger(0); + private volatile int sourceProbeRequestCount = 0; private String downloadName = null; private String downloadFolder = null; - private ApiManager apiManager = null; + private volatile ApiManager apiManager = null; private ActivityResultLauncher folderPickerLauncher; + private ActivityResultLauncher optimizeLauncher; + private ActivityResultLauncher passwordLauncher; + private ActivityResultLauncher linuxOptimizeLauncher; + private ActivityResultLauncher linuxResizeLauncher; + private ActivityResultLauncher linuxMaintenanceLauncher; + private boolean linuxVmMode = false; + private boolean vmRootPasswordVisible = false; + @Nullable + private UUID pendingPasswordDiskId; + @NonNull + private String pendingResetPassword = ""; + @NonNull + private String pendingImportName = ""; + @Nullable + private UUID pendingLinuxDiskId; + @NonNull + private String pendingLinuxVmName = ""; + @NonNull + private String pendingLinuxRootPassword = ""; + @NonNull + private String pendingLinuxNetworkId = ""; + private long pendingLinuxCpu = 1; + private long pendingLinuxMemoryMb = 512; + private long pendingLinuxDiskBytes = 16L * 1024 * 1024 * 1024; private long currentDownloadId = -1; + private boolean activityStarted = false; private final Handler pollHandler = new Handler(Looper.getMainLooper()); @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + linuxVmMode = isLinuxVmMode(); setContentView(R.layout.activity_import_lxc_images); notifPermission = new NotificationPermission(this); collapsingToolbar = findViewById(R.id.collapsing_toolbar); @@ -134,6 +238,15 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { tvOutputHeader = findViewById(R.id.tv_output_header); inputFilename = findViewById(R.id.input_filename); inputFolder = findViewById(R.id.input_folder); + inputResetPassword = findViewById(R.id.input_reset_password); + dividerSettings = findViewById(R.id.divider_settings); + tvSettingsHeader = findViewById(R.id.tv_settings_header); + inputVmName = findViewById(R.id.input_vm_name); + inputVmCpu = findViewById(R.id.input_vm_cpu); + inputVmMemory = findViewById(R.id.input_vm_memory); + inputVmDiskSize = findViewById(R.id.input_vm_disk_size); + inputVmRootPassword = findViewById(R.id.input_vm_root_password); + dropdownVmNetwork = findViewById(R.id.dropdown_vm_network); cardInfo = findViewById(R.id.card_info); tvInfoSize = findViewById(R.id.tv_info_size); tvInfoPath = findViewById(R.id.tv_info_path); @@ -143,11 +256,78 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { kernelAnalysis.setUrlProvider(() -> selectedImage == null ? null : pathJoin(getDownloadBaseUrl(), selectedImage.getDownloadPath())); scrollView = findViewById(R.id.scroll_view); + optimizeLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() == RESULT_OK) launchPasswordAfterImport(); + else completePendingImport(); + }); + passwordLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> + completePendingImport()); + linuxOptimizeLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() == RESULT_OK) launchLinuxResize(); + else finishLinuxVmFlow(false); + }); + linuxResizeLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() == RESULT_OK) launchLinuxMaintenance(); + else finishLinuxVmFlow(false); + }); + linuxMaintenanceLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), result -> { + if (result.getResultCode() == RESULT_OK) createPendingLinuxVm(); + else finishLinuxVmFlow(false); + }); + if (savedInstanceState != null) { + var diskId = savedInstanceState.getString(STATE_PENDING_PASSWORD_DISK_ID); + if (diskId != null) { + try { + pendingPasswordDiskId = UUID.fromString(diskId); + } catch (IllegalArgumentException e) { + Log.w(TAG, "Invalid pending password disk ID", e); + } + } + pendingResetPassword = requireNonNullElse( + savedInstanceState.getString(STATE_PENDING_RESET_PASSWORD), ""); + pendingImportName = requireNonNullElse( + savedInstanceState.getString(STATE_PENDING_IMPORT_NAME), ""); + pendingLinuxDiskId = parseUuid(savedInstanceState.getString( + STATE_PENDING_LINUX_DISK_ID), "pending Linux disk"); + pendingLinuxVmName = requireNonNullElse( + savedInstanceState.getString(STATE_PENDING_LINUX_VM_NAME), ""); + pendingLinuxRootPassword = requireNonNullElse( + savedInstanceState.getString(STATE_PENDING_LINUX_ROOT_PASSWORD), ""); + pendingLinuxNetworkId = requireNonNullElse( + savedInstanceState.getString(STATE_PENDING_LINUX_NETWORK_ID), ""); + pendingLinuxCpu = savedInstanceState.getLong(STATE_PENDING_LINUX_CPU, 1); + pendingLinuxMemoryMb = savedInstanceState.getLong( + STATE_PENDING_LINUX_MEMORY_MB, 512); + pendingLinuxDiskBytes = savedInstanceState.getLong( + STATE_PENDING_LINUX_DISK_BYTES, 16L * 1024 * 1024 * 1024); + } initialize(); } + /** Subclass entry point keeps download notifications mode-safe without duplicating logic. */ + protected boolean isLinuxVmMode() { + return false; + } + + @Nullable + private UUID parseUuid(@Nullable String value, @NonNull String label) { + if (value == null) return null; + try { + return UUID.fromString(value); + } catch (IllegalArgumentException e) { + Log.w(TAG, fmt("Invalid %s ID", label), e); + return null; + } + } + private void initialize() { - collapsingToolbar.setTitle(getString(R.string.lxc_title)); + collapsingToolbar.setTitle(getString( + linuxVmMode ? R.string.linux_vm_create_title : R.string.lxc_title)); toolbar.setNavigationOnClickListener(v -> confirmExit()); getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) { @Override @@ -162,61 +342,189 @@ public void handleOnBackPressed() { inputFolder.setText(path); inputFolder.setIconButtonOnClickListener(() -> folderPickerLauncher.launch(null)); fabImport.setOnClickListener(v -> doImport()); - // show the built-in source list immediately (screen usable at once), - // then refresh from the API in the background and swap it in when ready + configureModeUi(); + setupVmNetworkDropdown(); + sourcePrefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + boolean hasRememberedSources = loadRememberedSources(); + loadRememberedCustomUrls(); + setupCustomUrlPersistence(); + // Show bundled download mirrors immediately. Metadata deliberately has + // only Official, ClassFun and Custom; mirrors remain download-only. loadBuiltinSources(); - runOnPool(this::asyncRefreshSources); // If this screen was re-created while its download is still running, // restore the whole form; otherwise just re-attach the progress bar. - if (!restoreSession()) reattachActiveDownload(); + boolean restoredSession = restoreSession(); + if (!restoredSession) reattachActiveDownload(); + boolean shouldProbe = !hasRememberedSources && !restoredSession && !isDownloading; + boolean shouldAutoLoad = hasRememberedSources && !restoredSession && !isDownloading; + automaticMetadataRequest = shouldProbe || shouldAutoLoad; + if (shouldProbe) { + isProbingSources = true; + isLoading = true; + setMetaLoading(); + } else if (shouldAutoLoad) { + // loadImages() is posted after ClassFun initialization. Do not mark + // isLoading yet or that call would reject itself. + setMetaLoading(); + } else if (!restoredSession && !isDownloading) { + setMetaRefreshing(); + } + runOnPool(() -> asyncInitializeSources(shouldProbe, shouldAutoLoad)); + } + + private void configureModeUi() { + dividerOutput.setVisibility(linuxVmMode ? GONE : VISIBLE); + tvOutputHeader.setVisibility(linuxVmMode ? GONE : VISIBLE); + dividerSettings.setVisibility(linuxVmMode ? VISIBLE : GONE); + tvSettingsHeader.setVisibility(linuxVmMode ? VISIBLE : GONE); + // Both password fields ride through the temp rescue VM's chpasswd + // script; only characters that script may carry can be entered. + inputResetPassword.setFilters(shellSafePasswordFilter()); + inputVmRootPassword.setFilters(shellSafePasswordFilter()); + if (!linuxVmMode) return; + inputVmCpu.setValue(1); + inputVmMemory.setValue(512, SizeUnit.MB); + inputVmDiskSize.setValue(16, SizeUnit.GB); + inputVmRootPassword.setEndIconContentDescription(getString(R.string.field_copy)); + inputVmRootPassword.setEndIconOnClickListener(v -> copyVmRootPassword()); + inputVmRootPassword.setIconButtonOnClickListener(this::toggleVmRootPasswordVisible); + fabImport.setText(R.string.linux_vm_create_action); + setOutputEnabled(false); + } + + private void copyVmRootPassword() { + var password = inputVmRootPassword.getText(); + if (password.isEmpty()) return; + CopyableField.copySensitive( + this, password, getString(R.string.linux_vm_root_password_hint)); + } + + private void toggleVmRootPasswordVisible() { + vmRootPasswordVisible = !vmRootPasswordVisible; + inputVmRootPassword.setPasswordVisible(vmRootPasswordVisible); + inputVmRootPassword.setIconButtonIcon( + vmRootPasswordVisible ? R.drawable.ic_eye_off : R.drawable.ic_eye); + } + + private void setupVmNetworkDropdown() { + var ids = new ArrayList(); + var labels = new ArrayList(); + ids.add(""); + labels.add(getString(R.string.linux_vm_network_none)); + var store = new NetworkStore(); + store.load(this); + store.forEach((id, config) -> { + ids.add(id.toString()); + labels.add(config.getName()); + }); + vmNetworkIds = ids.toArray(new String[0]); + vmNetworkLabels = labels.toArray(new String[0]); + dropdownVmNetwork.setAdapter(IconItemAdapter.create( + this, vmNetworkLabels, R.drawable.ic_nav_network)); + dropdownVmNetwork.setOnItemClickListener((p, v, pos, id) -> + selectedVmNetworkId = vmNetworkIds[pos]); + // A fresh quick-create VM joins the first configured network. Keep the explicit + // no-network row as the fallback when the store is empty and as a manual choice. + if (selectedVmNetworkId.isEmpty() && vmNetworkIds.length > 1) + selectedVmNetworkId = vmNetworkIds[1]; + applyVmNetworkSelection(selectedVmNetworkId); + } + + private void applyVmNetworkSelection(@Nullable String networkId) { + var id = requireNonNullElse(networkId, ""); + int index = indexOfSource(vmNetworkIds, id); + if (index < 0) index = 0; + selectedVmNetworkId = vmNetworkIds[index]; + dropdownVmNetwork.setText(vmNetworkLabels[index]); } /** Snapshot of the form, kept across activity re-creation while a download runs. */ private static final class Session { + boolean linuxVmMode; + long downloadId = -1; String metaSource, customMetaUrl, dlSource, customDlUrl; List allImages; String distro, version, variant, build; - String filename, folder; + String filename, folder, resetPassword; + String vmName, vmRootPassword, vmNetworkId; + long vmCpu, vmMemoryMb, vmDiskBytes; } - /** The in-progress import (only one download runs at a time), or {@code null}. */ - private static Session session; + /** Stable snapshot used to merge a remembered source with either repo list. */ + private static final class DownloadSource { + final String key; + final String name; + final String baseUrl; + + DownloadSource(@NonNull String key, @NonNull String name, @NonNull String baseUrl) { + this.key = key; + this.name = name; + this.baseUrl = baseUrl; + } + } + + /** + * Form/download state is isolated by concrete entry Activity. Linux VM creation + * and plain LXC import share this implementation, but must never overwrite one + * another while either screen is in the background. + */ + private static final Map sessions = new ConcurrentHashMap<>(); + + @NonNull + private String sessionKey() { + return getClass().getName(); + } + + @Nullable + private Session getSession() { + return sessions.get(sessionKey()); + } private void captureSession() { var s = new Session(); - s.metaSource = dropdownMetaSource.getText(); + s.linuxVmMode = linuxVmMode; + s.metaSource = getSelectedMetaSourceKey(); s.customMetaUrl = inputCustomMetaUrl.getText(); - s.dlSource = dropdownDlSource.getText(); + s.dlSource = getSelectedDlSourceKey(); s.customDlUrl = inputCustomDlUrl.getText(); s.allImages = new ArrayList<>(allImages); s.distro = dropdownDistro.getText(); s.version = dropdownVersion.getText(); s.variant = dropdownVariant.getText(); s.build = dropdownBuild.getText(); - s.filename = inputFilename.getText(); - s.folder = inputFolder.getText(); - session = s; + if (linuxVmMode) { + s.vmName = inputVmName.getText(); + s.vmCpu = inputVmCpu.getValue(); + s.vmMemoryMb = inputVmMemory.getValue(SizeUnit.MB); + s.vmDiskBytes = inputVmDiskSize.getValue(); + s.vmRootPassword = inputVmRootPassword.getText(); + s.vmNetworkId = selectedVmNetworkId; + } else { + s.filename = inputFilename.getText(); + s.folder = inputFolder.getText(); + s.resetPassword = inputResetPassword.getText(); + } + sessions.put(sessionKey(), s); } /** * Rebuilds the form from the last saved session (replays the * distro->version->variant->build cascade) and re-attaches the progress bar if a - * download is still running. The session is kept after the download ends too, - * so reopening restores the last selections (hit Load to refresh). Returns - * false if there's nothing to restore. + * download is still running or its terminal result has not yet been consumed. + * The session is removed when the download succeeds, fails or is cancelled. + * Returns false if there's nothing to restore. */ private boolean restoreSession() { - var s = session; - if (s == null) return false; + var s = getSession(); + if (s == null || s.linuxVmMode != linuxVmMode) return false; if (lxcRepo == null) return false; // sources unavailable; can't rebuild - dropdownMetaSource.setText(s.metaSource); + applySourceSelection(s.metaSource, s.dlSource); inputCustomMetaUrl.setText(s.customMetaUrl); inputCustomMetaUrl.setVisibility( - getSelectedMetaSourceKey().equals("custom") ? VISIBLE : GONE); - dropdownDlSource.setText(s.dlSource); + getSelectedMetaSourceKey().equals(SOURCE_CUSTOM) ? VISIBLE : GONE); inputCustomDlUrl.setText(s.customDlUrl); inputCustomDlUrl.setVisibility( - getSelectedDlSourceKey().equals("custom") ? VISIBLE : GONE); + getSelectedDlSourceKey().equals(SOURCE_CUSTOM) ? VISIBLE : GONE); onImagesLoaded(s.allImages, s.allImages.size()); dropdownDistro.setText(s.distro); onDistroSelected(s.distro); @@ -226,16 +534,71 @@ private boolean restoreSession() { onVariantSelected(s.variant); dropdownBuild.setText(s.build); onBuildSelected(s.build); - inputFilename.setText(s.filename); - inputFolder.setText(s.folder); - reattachActiveDownload(); + if (linuxVmMode) { + inputVmName.setText(s.vmName); + inputVmCpu.setValue(s.vmCpu); + inputVmMemory.setValue(s.vmMemoryMb, SizeUnit.MB); + inputVmDiskSize.setValue(s.vmDiskBytes); + inputVmRootPassword.setText(s.vmRootPassword); + applyVmNetworkSelection(s.vmNetworkId); + pendingLinuxVmName = requireNonNullElse(s.vmName, ""); + pendingLinuxRootPassword = requireNonNullElse(s.vmRootPassword, ""); + pendingLinuxNetworkId = requireNonNullElse(s.vmNetworkId, ""); + pendingLinuxCpu = s.vmCpu; + pendingLinuxMemoryMb = s.vmMemoryMb; + pendingLinuxDiskBytes = s.vmDiskBytes; + } else { + inputFilename.setText(s.filename); + inputFolder.setText(s.folder); + inputResetPassword.setText(s.resetPassword); + } + reattachSessionDownload(s); return true; } + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (pendingPasswordDiskId != null) + outState.putString( + STATE_PENDING_PASSWORD_DISK_ID, pendingPasswordDiskId.toString()); + if (!pendingResetPassword.isEmpty()) + outState.putString(STATE_PENDING_RESET_PASSWORD, pendingResetPassword); + if (!pendingImportName.isEmpty()) + outState.putString(STATE_PENDING_IMPORT_NAME, pendingImportName); + if (pendingLinuxDiskId != null) + outState.putString(STATE_PENDING_LINUX_DISK_ID, pendingLinuxDiskId.toString()); + if (!pendingLinuxVmName.isEmpty()) + outState.putString(STATE_PENDING_LINUX_VM_NAME, pendingLinuxVmName); + if (!pendingLinuxRootPassword.isEmpty()) + outState.putString( + STATE_PENDING_LINUX_ROOT_PASSWORD, pendingLinuxRootPassword); + if (!pendingLinuxNetworkId.isEmpty()) + outState.putString(STATE_PENDING_LINUX_NETWORK_ID, pendingLinuxNetworkId); + outState.putLong(STATE_PENDING_LINUX_CPU, pendingLinuxCpu); + outState.putLong(STATE_PENDING_LINUX_MEMORY_MB, pendingLinuxMemoryMb); + outState.putLong(STATE_PENDING_LINUX_DISK_BYTES, pendingLinuxDiskBytes); + } + /** Re-attaches just the progress bar to the running download (no form state). */ private void reattachActiveDownload() { long id = DiskDownloadManager.activeDownloadId(getClass().getName()); if (id < 0) return; + var s = getSession(); + if (s != null) s.downloadId = id; + attachDownload(id); + } + + /** Re-attaches to this form's exact job, including an unconsumed success. */ + private void reattachSessionDownload(@NonNull Session s) { + if (s.downloadId < 0 || DiskDownloadManager.query(s.downloadId) == null) { + reattachActiveDownload(); + return; + } + attachDownload(s.downloadId); + } + + private void attachDownload(long id) { currentDownloadId = id; isDownloading = true; setInputsEnabled(false); @@ -244,7 +607,62 @@ private void reattachActiveDownload() { var name = DiskDownloadManager.downloadName(id); downloadWidget.startExternal(name != null ? name : "", this::cancelDownload); scrollView.post(() -> scrollView.fullScroll(View.FOCUS_DOWN)); - pollHandler.post(pollRunnable); + if (activityStarted) pollHandler.post(pollRunnable); + } + + /** Returns true only when the complete, usable pair has already been remembered. */ + private boolean loadRememberedSources() { + if (!sourcePrefs.contains(PREF_META_SOURCE) || !sourcePrefs.contains(PREF_DL_SOURCE)) + return false; + var meta = sourcePrefs.getString(PREF_META_SOURCE, null); + var download = sourcePrefs.getString(PREF_DL_SOURCE, null); + if (!isAllowedMetadataSource(meta) || download == null || download.isEmpty()) { + sourcePrefs.edit() + .remove(PREF_META_SOURCE) + .remove(PREF_DL_SOURCE) + .remove(PREF_DL_SOURCE_NAME) + .remove(PREF_DL_SOURCE_URL) + .apply(); + return false; + } + selectedMetaSourceKey = meta; + selectedDlSourceKey = download; + rememberedDlSourceKey = download; + rememberedDlSourceName = requireNonNullElse( + sourcePrefs.getString(PREF_DL_SOURCE_NAME, ""), ""); + rememberedDlSourceUrl = requireNonNullElse( + sourcePrefs.getString(PREF_DL_SOURCE_URL, ""), ""); + // No probe will be started, but marking it settled also protects the + // remembered pair from any stale callback left in the process. + sourceProbeSettled.set(true); + return true; + } + + private boolean isAllowedMetadataSource(@Nullable String key) { + return SOURCE_OFFICIAL.equals(key) || SOURCE_CLASSFUN.equals(key) + || SOURCE_CUSTOM.equals(key); + } + + private void loadRememberedCustomUrls() { + inputCustomMetaUrl.setText(sourcePrefs.getString(PREF_CUSTOM_META_URL, "")); + inputCustomDlUrl.setText(sourcePrefs.getString(PREF_CUSTOM_DL_URL, "")); + } + + private void setupCustomUrlPersistence() { + inputCustomMetaUrl.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void afterTextChanged(Editable s) { + sourcePrefs.edit().putString(PREF_CUSTOM_META_URL, s.toString()).apply(); + } + }); + inputCustomDlUrl.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void afterTextChanged(Editable s) { + sourcePrefs.edit().putString(PREF_CUSTOM_DL_URL, s.toString()).apply(); + if (SOURCE_CUSTOM.equals(selectedDlSourceKey)) + rememberSourcePair(selectedMetaSourceKey, selectedDlSourceKey); + } + }); } /** @@ -253,73 +671,170 @@ private void reattachActiveDownload() { */ private void loadBuiltinSources() { var repos = Repos.loadYAML(this); - if (repos != null) lxcRepo = repos.getRepo().get("lxc-images"); + if (repos != null) builtinLxcRepo = repos.getRepo().get("lxc-images"); + lxcRepo = builtinLxcRepo; if (lxcRepo == null) { // bundled data missing/corrupt: fall back to a blocking load setMetaSourcesLoading(); return; } + hydrateRememberedDownloadSource(lxcRepo); setupSourceDropdown(); setupImageDropdowns(); - setMetaRefreshing(); } /** - * Background refresh: fetches the freshest repo/mirror list (and ClassFun - * availability) from the API, then swaps it into the dropdowns without - * disturbing a user who has already moved on to loading images. + * Resolves ClassFun first, starts the one-time race when needed, then refreshes + * the download mirror list. The two metadata GETs share a start gate so neither + * source gets an artificial scheduling head start. */ - private void asyncRefreshSources() { + private void asyncInitializeSources(boolean shouldProbe, boolean shouldAutoLoad) { boolean classfun = false; - Repos.Repo freshRepo = null; try { if (Privacy.isPrivacyAgreed(this)) { apiManager = ApiManager.create(this); classfun = apiManager.isServiceEnabled("lxc_images_metadata"); } + } catch (Exception e) { + Log.w(TAG, "Failed to initialize ClassFun metadata service", e); + } + final boolean classfunAvailable = classfun; + runOnUiThread(() -> { + isClassFunApiAvailable = classfunAvailable; + if (shouldAutoLoad && !isFinishing() && !isDestroyed()) loadImages(); + }); + + if (shouldProbe && !sourceProbeSettled.get()) + startSourceProbe(classfunAvailable); + + Repos.Repo freshRepo = null; + try { var repos = Repos.load(this); if (repos != null) freshRepo = repos.getRepo().get("lxc-images"); } catch (Exception e) { - Log.w(TAG, "Background source refresh failed; keeping built-in list", e); + Log.w(TAG, "Background mirror refresh failed; keeping built-in list", e); } - final boolean classfunAvailable = classfun; final Repos.Repo repo = freshRepo; runOnUiThread(() -> applyRefreshedSources(repo, classfunAvailable)); } private void applyRefreshedSources(@Nullable Repos.Repo freshRepo, boolean classfunAvailable) { - if (isFinishing()) return; + if (isFinishing() || isDestroyed()) return; isClassFunApiAvailable = classfunAvailable; if (freshRepo != null) { - // capture any explicit choice before the list swap - var metaSel = getSelectedMetaSourceKey(); - var dlSel = getSelectedDlSourceKey(); + boolean sourcesWereMissing = lxcRepo == null; + // A second import screen may update the globally remembered preference while this + // screen's download is in the background. Keep the source labels from this exact + // session until its job is consumed; the new preference applies next time. + var metaSel = isDownloading ? getSelectedMetaSourceKey() : sourcePrefs.getString( + PREF_META_SOURCE, getSelectedMetaSourceKey()); + var dlSel = isDownloading ? getSelectedDlSourceKey() : sourcePrefs.getString( + PREF_DL_SOURCE, getSelectedDlSourceKey()); lxcRepo = freshRepo; + selectedMetaSourceKey = requireNonNullElse(metaSel, SOURCE_OFFICIAL); + selectedDlSourceKey = requireNonNullElse(dlSel, SOURCE_OFFICIAL); + hydrateRememberedDownloadSource(freshRepo); setupSourceDropdown(); - // keep an explicit pick; otherwise take the fresh default - // (which now prefers ClassFun when it just became available) - if (userTouchedSource) restoreSourceSelection(metaSel, dlSel); + if (sourcesWereMissing) setupImageDropdowns(); } else if (lxcRepo == null) { // both the bundled list and the refresh failed setMetaError("source list unavailable"); btnLoad.setEnabled(false); return; } - // clear the "refreshing" spinner only if the user hasn't moved on - if (!isLoading && !isDownloading && allImages.isEmpty()) + // Never replace an automatic load's progress/error with "load metadata". + if (!automaticMetadataRequest && !isLoading && !isDownloading && allImages.isEmpty()) setMetaIdle(); + automaticMetadataRequest = false; } - /** Re-applies a previously selected source key after the list is rebuilt. */ - private void restoreSourceSelection(@NonNull String metaKey, @NonNull String dlKey) { - int mi = findSourceIndex(metaSourceKeys, metaKey); - dropdownMetaSource.setText(metaSourceLabels[mi]); - inputCustomMetaUrl.setVisibility( - metaSourceKeys[mi].equals("custom") ? VISIBLE : GONE); - int di = findSourceIndex(dlSourceKeys, dlKey); - dropdownDlSource.setText(dlSourceLabels[di]); - inputCustomDlUrl.setVisibility( - dlSourceKeys[di].equals("custom") ? VISIBLE : GONE); + private void startSourceProbe(boolean classfunAvailable) { + var repo = lxcRepo; + if (repo == null) { + if (sourceProbeSettled.compareAndSet(false, true)) + finishSourceProbeFailure("Official LXC source unavailable"); + return; + } + var officialUrl = pathJoin(repo.getUrl(), IMAGES_META_PATH); + String classfunUrl = null; + if (classfunAvailable && apiManager != null) { + try { + classfunUrl = apiManager.getApiUrl("lxc_images_metadata"); + } catch (Exception e) { + Log.w(TAG, "ClassFun metadata probe unavailable", e); + } + } + + sourceProbeFailures.set(0); + sourceProbeRequestCount = classfunUrl == null ? 1 : 2; + var startGate = new CountDownLatch(1); + runOnPool(() -> probeMetadataSource( + SOURCE_OFFICIAL, SOURCE_OFFICIAL, officialUrl, startGate)); + if (classfunUrl != null) { + final var url = classfunUrl; + runOnPool(() -> probeMetadataSource( + SOURCE_CLASSFUN, SOURCE_CERNET, url, startGate)); + } + startGate.countDown(); + } + + private void probeMetadataSource( + @NonNull String metadataSource, + @NonNull String downloadSource, + @NonNull String url, + @NonNull CountDownLatch startGate + ) { + try { + startGate.await(); + if (sourceProbeSettled.get()) return; + var json = fetchJSON(url, BROWSER_USER_AGENT); + if (!sourceProbeSettled.compareAndSet(false, true)) return; + // fetchJSON has already validated the response. Settle the race at + // response completion; parsing time must not change which source won. + var images = LxcImageParser.parse(json); + Log.i(TAG, fmt("Initial metadata probe selected %s", metadataSource)); + runOnUiThread(() -> applySourceProbeWinner( + metadataSource, downloadSource, images)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + recordSourceProbeFailure(metadataSource, e); + } catch (Exception e) { + recordSourceProbeFailure(metadataSource, e); + } + } + + private void recordSourceProbeFailure(@NonNull String source, @NonNull Exception error) { + Log.w(TAG, fmt("Metadata probe failed for %s", source), error); + if (sourceProbeSettled.get()) return; + if (sourceProbeFailures.incrementAndGet() < sourceProbeRequestCount) return; + if (!sourceProbeSettled.compareAndSet(false, true)) return; + var message = error.getMessage(); + finishSourceProbeFailure(message != null ? message : "Unknown error"); + } + + private void finishSourceProbeFailure(@NonNull String message) { + runOnUiThread(() -> { + if (isFinishing() || isDestroyed() || userOverrodeSourceProbe.get()) return; + isProbingSources = false; + isLoading = false; + setMetaError(message); + }); + } + + private void applySourceProbeWinner( + @NonNull String metadataSource, + @NonNull String downloadSource, + @NonNull List images + ) { + if (isFinishing() || isDestroyed() || userOverrodeSourceProbe.get()) return; + rememberSourcePair(metadataSource, downloadSource); + // The refreshed repo may not contain the probe's CERNET entry. Rebuild + // from the remembered snapshot so the winning source is always selectable. + setupSourceDropdown(); + applySourceSelection(metadataSource, downloadSource); + isProbingSources = false; + isLoading = false; + onImagesLoaded(images, images.size()); } private void setMetaRefreshing() { @@ -342,7 +857,24 @@ private void setMetaSourcesLoading() { private boolean sourcesReady() { return metaSourceKeys != null && metaSourceLabels != null - && dlSourceKeys != null && dlSourceLabels != null; + && dlSourceKeys != null && dlSourceLabels != null && dlSourceUrls != null; + } + + @Override + protected void onStart() { + super.onStart(); + activityStarted = true; + if (currentDownloadId >= 0) { + pollHandler.removeCallbacks(pollRunnable); + pollHandler.post(pollRunnable); + } + } + + @Override + protected void onStop() { + activityStarted = false; + pollHandler.removeCallbacks(pollRunnable); + super.onStop(); } @Override @@ -411,62 +943,243 @@ private int resolveThemeColor(int attr) { } private void setupSourceDropdown() { - var keys = new ArrayList(); - var labels = new ArrayList(); - if (isClassFunApiAvailable) { - keys.add("classfun"); - labels.add(getString(R.string.lxc_source_classfun)); - } - keys.add("official"); - labels.add(getString(R.string.lxc_source_official)); - for (var mirror : lxcRepo.getMirrors()) { - keys.add(mirror.getId()); - labels.add(mirror.getName()); - } - keys.add("custom"); - labels.add(getString(R.string.lxc_source_custom)); - metaSourceKeys = keys.toArray(new String[0]); - metaSourceLabels = labels.toArray(new String[0]); - dlSourceKeys = keys.toArray(new String[0]); - dlSourceLabels = labels.toArray(new String[0]); - int metaDefaultIndex = findSourceIndex(metaSourceKeys, - isClassFunApiAvailable ? "classfun" : "official"); + var metaKeys = new ArrayList(); + var metaLabels = new ArrayList(); + metaKeys.add(SOURCE_OFFICIAL); + metaLabels.add(getString(R.string.lxc_source_official)); + metaKeys.add(SOURCE_CLASSFUN); + metaLabels.add(getString(R.string.lxc_source_classfun)); + metaKeys.add(SOURCE_CUSTOM); + metaLabels.add(getString(R.string.lxc_source_custom)); + metaSourceKeys = metaKeys.toArray(new String[0]); + metaSourceLabels = metaLabels.toArray(new String[0]); + + var downloadSources = buildDownloadSources(lxcRepo); + dlSourceKeys = new String[downloadSources.size()]; + dlSourceLabels = new String[downloadSources.size()]; + dlSourceUrls = new String[downloadSources.size()]; + int sourceIndex = 0; + for (var source : downloadSources.values()) { + dlSourceKeys[sourceIndex] = source.key; + dlSourceLabels[sourceIndex] = source.name; + dlSourceUrls[sourceIndex] = source.baseUrl; + sourceIndex++; + } + + int metaDefaultIndex = findSourceIndex(metaSourceKeys, selectedMetaSourceKey); var aMeta = IconItemAdapter.create(this, metaSourceLabels, R.drawable.ic_nav_network); dropdownMetaSource.setAdapter(aMeta); dropdownMetaSource.setText(metaSourceLabels[metaDefaultIndex]); + selectedMetaSourceKey = metaSourceKeys[metaDefaultIndex]; dropdownMetaSource.setOnItemClickListener((p, v, pos, id) -> { - userTouchedSource = true; - boolean isCustom = metaSourceKeys[pos].equals("custom"); + selectedMetaSourceKey = metaSourceKeys[pos]; + boolean isCustom = selectedMetaSourceKey.equals(SOURCE_CUSTOM); inputCustomMetaUrl.setVisibility(isCustom ? VISIBLE : GONE); + onUserChangedSource(); setMetaIdle(); }); inputCustomMetaUrl.setVisibility( - metaSourceKeys[metaDefaultIndex].equals("custom") ? VISIBLE : GONE); - int downDefaultIndex = findSourceIndex(dlSourceKeys, - getString(R.string.lxc_default_download_source)); + selectedMetaSourceKey.equals(SOURCE_CUSTOM) ? VISIBLE : GONE); + + int downDefaultIndex = findSourceIndex(dlSourceKeys, selectedDlSourceKey); var aDown = IconItemAdapter.create(this, dlSourceLabels, R.drawable.ic_download); dropdownDlSource.setAdapter(aDown); dropdownDlSource.setText(dlSourceLabels[downDefaultIndex]); + selectedDlSourceKey = dlSourceKeys[downDefaultIndex]; dropdownDlSource.setOnItemClickListener((p, v, pos, id) -> { - userTouchedSource = true; - boolean isCustom = dlSourceKeys[pos].equals("custom"); + selectedDlSourceKey = dlSourceKeys[pos]; + boolean isCustom = selectedDlSourceKey.equals(SOURCE_CUSTOM); inputCustomDlUrl.setVisibility(isCustom ? VISIBLE : GONE); + onUserChangedSource(); }); inputCustomDlUrl.setVisibility( - dlSourceKeys[downDefaultIndex].equals("custom") ? VISIBLE : GONE); + selectedDlSourceKey.equals(SOURCE_CUSTOM) ? VISIBLE : GONE); } private int findSourceIndex(@NonNull String[] keys, @NonNull String target) { + int index = indexOfSource(keys, target); + return index >= 0 ? index : 0; + } + + private int indexOfSource(@Nullable String[] keys, @NonNull String target) { + if (keys == null) return -1; for (int i = 0; i < keys.length; i++) if (keys[i].equals(target)) return i; - return 0; + return -1; + } + + /** + * Before Repos.load() completes this is the union of remembered and + * repo.yaml. Afterwards lxcRepo points at Repos.load(), so rebuilding + * produces the union of remembered and remote. + * putIfAbsent makes the exact remembered name/base URL win on key collisions. + */ + @NonNull + private LinkedHashMap buildDownloadSources( + @Nullable Repos.Repo repo + ) { + var result = new LinkedHashMap(); + if (!rememberedDlSourceKey.isEmpty()) { + var rememberedName = rememberedDlSourceName.isEmpty() + ? rememberedDlSourceKey : rememberedDlSourceName; + if (!rememberedDlSourceUrl.isEmpty() || SOURCE_CUSTOM.equals(rememberedDlSourceKey)) { + result.put(rememberedDlSourceKey, new DownloadSource( + rememberedDlSourceKey, rememberedName, rememberedDlSourceUrl)); + } + } + addRepoDownloadSources(result, repo); + result.putIfAbsent(SOURCE_CUSTOM, new DownloadSource( + SOURCE_CUSTOM, + getString(R.string.lxc_source_custom), + normalizeBaseUrl(inputCustomDlUrl.getText()) + )); + return result; + } + + private void addRepoDownloadSources( + @NonNull LinkedHashMap result, + @Nullable Repos.Repo repo + ) { + if (repo == null) return; + result.putIfAbsent(SOURCE_OFFICIAL, new DownloadSource( + SOURCE_OFFICIAL, + getString(R.string.lxc_source_official), + normalizeBaseUrl(repo.getUrl()) + )); + for (var mirror : repo.getMirrors()) { + var url = normalizeBaseUrl(mirror.getRepoUrl(repo)); + if (url.isEmpty()) continue; + result.putIfAbsent(mirror.getId(), new DownloadSource( + mirror.getId(), mirror.getName(), url)); + } + } + + @Nullable + private DownloadSource findDownloadSourceInRepo( + @NonNull String key, + @Nullable Repos.Repo repo + ) { + if (repo == null || SOURCE_CUSTOM.equals(key)) return null; + if (SOURCE_OFFICIAL.equals(key)) { + return new DownloadSource( + key, + getString(R.string.lxc_source_official), + normalizeBaseUrl(repo.getUrl()) + ); + } + var mirror = repo.getMirror(key); + if (mirror == null) return null; + var url = normalizeBaseUrl(mirror.getRepoUrl(repo)); + if (url.isEmpty()) return null; + return new DownloadSource(key, mirror.getName(), url); + } + + @Nullable + private DownloadSource findCurrentDownloadSource(@NonNull String key) { + int index = indexOfSource(dlSourceKeys, key); + if (index >= 0 && dlSourceLabels != null && dlSourceUrls != null) { + return new DownloadSource( + key, dlSourceLabels[index], dlSourceUrls[index]); + } + var source = findDownloadSourceInRepo(key, lxcRepo); + if (source == null && builtinLxcRepo != lxcRepo) + source = findDownloadSourceInRepo(key, builtinLxcRepo); + return source; + } + + /** Migrates old key-only preferences, while never replacing a saved URL. */ + private void hydrateRememberedDownloadSource(@Nullable Repos.Repo repo) { + if (rememberedDlSourceKey.isEmpty()) return; + String name = rememberedDlSourceName; + String url = rememberedDlSourceUrl; + DownloadSource fallback = findDownloadSourceInRepo(rememberedDlSourceKey, repo); + if (fallback == null && builtinLxcRepo != repo) + fallback = findDownloadSourceInRepo(rememberedDlSourceKey, builtinLxcRepo); + if (SOURCE_CUSTOM.equals(rememberedDlSourceKey)) { + if (name.isEmpty()) name = getString(R.string.lxc_source_custom); + var customUrl = normalizeBaseUrl(inputCustomDlUrl.getText()); + if (!customUrl.isEmpty()) url = customUrl; + } else if (fallback != null) { + if (name.isEmpty()) name = fallback.name; + if (url.isEmpty()) url = fallback.baseUrl; + } + if (name.isEmpty()) name = rememberedDlSourceKey; + rememberedDlSourceName = name; + rememberedDlSourceUrl = url; + sourcePrefs.edit() + .putString(PREF_DL_SOURCE_NAME, name) + .putString(PREF_DL_SOURCE_URL, url) + .apply(); + } + + private void applySourceSelection(@NonNull String metaKey, @NonNull String dlKey) { + int mi = findSourceIndex(metaSourceKeys, metaKey); + selectedMetaSourceKey = metaSourceKeys[mi]; + dropdownMetaSource.setText(metaSourceLabels[mi]); + inputCustomMetaUrl.setVisibility( + selectedMetaSourceKey.equals(SOURCE_CUSTOM) ? VISIBLE : GONE); + int di = findSourceIndex(dlSourceKeys, dlKey); + selectedDlSourceKey = dlSourceKeys[di]; + dropdownDlSource.setText(dlSourceLabels[di]); + inputCustomDlUrl.setVisibility( + selectedDlSourceKey.equals(SOURCE_CUSTOM) ? VISIBLE : GONE); + } + + private void onUserChangedSource() { + boolean wasProbing = isProbingSources; + automaticMetadataRequest = false; + userOverrodeSourceProbe.set(true); + sourceProbeSettled.set(true); + rememberSourcePair(getSelectedMetaSourceKey(), getSelectedDlSourceKey()); + if (!wasProbing) return; + isProbingSources = false; + isLoading = false; + setMetaIdle(); + } + + private void rememberSourcePair(@NonNull String metadataSource, @NonNull String downloadSource) { + DownloadSource source; + if (SOURCE_CUSTOM.equals(downloadSource)) { + source = new DownloadSource( + SOURCE_CUSTOM, + getString(R.string.lxc_source_custom), + normalizeBaseUrl(inputCustomDlUrl.getText()) + ); + } else { + source = findCurrentDownloadSource(downloadSource); + } + if (source == null) { + String previousName = downloadSource.equals(rememberedDlSourceKey) + ? rememberedDlSourceName : downloadSource; + String previousUrl = downloadSource.equals(rememberedDlSourceKey) + ? rememberedDlSourceUrl : ""; + source = new DownloadSource(downloadSource, previousName, previousUrl); + } + rememberedDlSourceKey = source.key; + rememberedDlSourceName = source.name; + rememberedDlSourceUrl = source.baseUrl; + sourcePrefs.edit() + .putString(PREF_META_SOURCE, metadataSource) + .putString(PREF_DL_SOURCE, downloadSource) + .putString(PREF_DL_SOURCE_NAME, source.name) + .putString(PREF_DL_SOURCE_URL, source.baseUrl) + .apply(); + } + + @NonNull + private String normalizeBaseUrl(@Nullable String url) { + if (url == null) return ""; + var result = url.trim(); + while (result.endsWith("/") && !result.endsWith("://")) + result = result.substring(0, result.length() - 1); + return result; } @Nullable private String resolveSourceUrl(@NonNull String key) { if (lxcRepo == null) return null; - if (key.equals("official")) return lxcRepo.getUrl(); - if (key.equals("custom")) return null; + if (key.equals(SOURCE_OFFICIAL)) return lxcRepo.getUrl(); + if (key.equals(SOURCE_CUSTOM)) return null; var mirror = lxcRepo.getMirror(key); if (mirror == null) return null; return mirror.getRepoUrl(lxcRepo); @@ -475,15 +1188,13 @@ private String resolveSourceUrl(@NonNull String key) { @NonNull private String getMetaBaseUrl() { var key = getSelectedMetaSourceKey(); - if (key.equals("custom")) { - var url = inputCustomMetaUrl.getText().trim(); - while (url.endsWith("/")) url = url.substring(0, url.length() - 1); - return url; + if (key.equals(SOURCE_CUSTOM)) { + return normalizeBaseUrl(inputCustomMetaUrl.getText()); } - if (key.equals("classfun")) { - if (isClassFunApiAvailable) + if (key.equals(SOURCE_CLASSFUN)) { + if (isClassFunApiAvailable && apiManager != null) return apiManager.getApiUrl("lxc_images_metadata"); - key = "official"; + return ""; } var base = resolveSourceUrl(key); if (base == null) return ""; @@ -493,20 +1204,23 @@ private String getMetaBaseUrl() { @NonNull private String getDownloadBaseUrl() { var key = getSelectedDlSourceKey(); - if (key.equals("custom")) { - var url = inputCustomDlUrl.getText().trim(); - while (url.endsWith("/")) url = url.substring(0, url.length() - 1); - return url; + if (key.equals(SOURCE_CUSTOM)) { + return normalizeBaseUrl(inputCustomDlUrl.getText()); } - return requireNonNullElse(resolveSourceUrl(key), ""); + int index = indexOfSource(dlSourceKeys, key); + if (index >= 0 && dlSourceUrls != null) return dlSourceUrls[index]; + if (key.equals(rememberedDlSourceKey)) return rememberedDlSourceUrl; + return ""; } private void loadImages() { if (isLoading || isDownloading || !sourcesReady()) return; var baseUrl = getMetaBaseUrl(); if (baseUrl.isEmpty()) { - if (getSelectedMetaSourceKey().equals("custom")) + if (getSelectedMetaSourceKey().equals(SOURCE_CUSTOM)) inputCustomMetaUrl.setError(getString(R.string.lxc_error_custom_url)); + else + setMetaError(getString(R.string.lxc_error_source_unavailable)); return; } inputCustomMetaUrl.setError(null); @@ -546,8 +1260,10 @@ private void onImagesLoaded(@NonNull List images, int count) { } private void setupImageDropdowns() { - dropdownDistro.setOnItemClickListener((p, v, pos, id) -> - onDistroSelected(dropdownDistro.getText())); + dropdownDistro.setOnItemClickListener((p, v, pos, id) -> { + onDistroSelected(dropdownDistro.getText()); + autoSelectNewestImage(); + }); dropdownVersion.setOnItemClickListener((p, v, pos, id) -> onVersionSelected(dropdownVersion.getText())); dropdownVariant.setOnItemClickListener((p, v, pos, id) -> @@ -556,6 +1272,38 @@ private void setupImageDropdowns() { onBuildSelected(dropdownBuild.getText())); } + /** + * Quick-create convenience: tapping a distro completes the rest of the + * image choice in one go - newest version, cloud-first variant, newest + * build - and every dropdown stays manually changeable afterwards. Only a + * real distro tap triggers this; a session restore replays the cascade + * with its saved values instead. + */ + private void autoSelectNewestImage() { + if (!linuxVmMode) return; + String version = null; + for (var v : displayVersionToRelease.keySet()) version = v; // ascending: last = newest + if (version == null) return; + dropdownVersion.setText(version); + var variants = onVersionSelected(version); + if (variants.length == 0) return; + var variant = pickAutoVariant(variants); + dropdownVariant.setText(variant); + var builds = onVariantSelected(variant); + if (builds.length == 0) return; + var build = builds[0]; // build list is reverse-sorted: first = newest + dropdownBuild.setText(build); + onBuildSelected(build); + } + + /** cloud boots leanest for a VM, default is the stock image; else the list's last entry. */ + @NonNull + private static String pickAutoVariant(@NonNull String[] variants) { + for (var v : variants) if (v.equalsIgnoreCase("cloud")) return v; + for (var v : variants) if (v.equalsIgnoreCase("default")) return v; + return variants[variants.length - 1]; + } + private void populateDistros() { var distros = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); for (var img : allImages) distros.add(img.getDistro()); @@ -585,7 +1333,8 @@ private void onDistroSelected(String distro) { setOutputEnabled(false); } - private void onVersionSelected(String displayVersion) { + /** Populates the variant dropdown; returns its items for the auto-select chain. */ + private String[] onVersionSelected(String displayVersion) { var distro = dropdownDistro.getText(); var release = displayVersionToRelease.get(displayVersion); if (release == null) release = displayVersion; @@ -593,14 +1342,17 @@ private void onVersionSelected(String displayVersion) { for (var img : allImages) if (img.getDistro().equals(distro) && img.getDistroVersion().equals(release)) variants.add(img.getVariant()); - setDropdownItems(dropdownVariant, variants.toArray(new String[0]), R.drawable.ic_package); + var items = variants.toArray(new String[0]); + setDropdownItems(dropdownVariant, items, R.drawable.ic_package); dropdownVariant.setEnabled(true); clearDropdown(dropdownBuild, R.drawable.ic_wrench); dropdownBuild.setEnabled(false); setOutputEnabled(false); + return items; } - private void onVariantSelected(String variant) { + /** Populates the build dropdown (newest first); returns its items for auto-select. */ + private String[] onVariantSelected(String variant) { var distro = dropdownDistro.getText(); var displayVersion = dropdownVersion.getText(); var release = displayVersionToRelease.get(displayVersion); @@ -612,9 +1364,11 @@ private void onVariantSelected(String variant) { img.getVariant().equals(variant) ) builds.add(img.getBuildSerial()); } - setDropdownItems(dropdownBuild, builds.toArray(new String[0]), R.drawable.ic_wrench); + var items = builds.toArray(new String[0]); + setDropdownItems(dropdownBuild, items, R.drawable.ic_wrench); dropdownBuild.setEnabled(true); setOutputEnabled(false); + return items; } private void onBuildSelected(String build) { @@ -638,6 +1392,17 @@ private void onBuildSelected(String build) { private void showOutput(@NonNull LxcImage img) { setOutputEnabled(true); + if (linuxVmMode) { + if (inputVmName.getText().isEmpty()) + inputVmName.setText(defaultLinuxVmName(img)); + // Offer a generated default, but never replace what the user typed + // (a session restore re-applies the saved password right after this). + if (inputVmRootPassword.getText().isEmpty()) + inputVmRootPassword.setText(generateGroupedPassword()); + kernelAnalysis.setVisibility(VISIBLE); + kernelAnalysis.reset(); + return; + } inputFilename.setText(img.getDefaultFileName()); tvInfoSize.setText(getString(R.string.lxc_info_size, formatSize(img.getSize()))); var downloadUrl = pathJoin(getDownloadBaseUrl(), img.getDownloadPath()); @@ -648,6 +1413,19 @@ private void showOutput(@NonNull LxcImage img) { kernelAnalysis.reset(); } + @NonNull + private String defaultLinuxVmName(@NonNull LxcImage image) { + var base = fmt("%s-%s", image.getDistro(), image.getDistroVersion()) + .replace(":", "-"); + if (!checkFileName(base)) base = "Linux-VM"; + var store = new VMStore(); + store.load(this); + if (store.isNameUnique(base)) return base; + int suffix = 2; + while (!store.isNameUnique(fmt("%s-%d", base, suffix))) suffix++; + return fmt("%s-%d", base, suffix); + } + private void hideOutput() { selectedImage = null; setOutputEnabled(false); @@ -666,6 +1444,10 @@ private void doImport() { ).show(); return; } + if (linuxVmMode) { + doLinuxVmDownload(); + return; + } var name = inputFilename.getText(); if (name.isEmpty()) { inputFilename.setError(getString(R.string.lxc_error_name_empty)); @@ -678,9 +1460,16 @@ private void doImport() { return; } inputFolder.setError(null); + var resetPassword = inputResetPassword.getText(); + if (!resetPassword.isEmpty() && !isShellSafePassword(resetPassword)) { + inputResetPassword.setError(getString( + R.string.change_password_error_unsafe, SHELL_SAFE_PASSWORD_SYMBOLS)); + return; + } + inputResetPassword.setError(null); var downloadBaseUrl = getDownloadBaseUrl(); if (downloadBaseUrl.isEmpty()) { - if (getSelectedDlSourceKey().equals("custom")) + if (getSelectedDlSourceKey().equals(SOURCE_CUSTOM)) inputCustomDlUrl.setError(getString(R.string.lxc_error_custom_url)); return; } @@ -696,6 +1485,96 @@ private void doImport() { notifPermission.ensureThen(() -> startDownload(downloadUrl)); } + private void doLinuxVmDownload() { + var vmName = inputVmName.getText(); + inputVmName.setError(null); + if (vmName.isEmpty()) { + inputVmName.setError(getString(R.string.create_vm_error_name_empty)); + return; + } + if (!checkFileName(vmName)) { + inputVmName.setError(getString(R.string.create_vm_error_name_invalid)); + return; + } + var vmStore = new VMStore(); + vmStore.load(this); + if (!vmStore.isNameUnique(vmName)) { + inputVmName.setError(getString(R.string.create_vm_error_name_duplicate)); + return; + } + inputVmCpu.setError(null); + inputVmMemory.setError(null); + inputVmDiskSize.setError(null); + final long cpu; + final long memoryMb; + final long diskBytes; + try { + if (!inputVmCpu.isInputValid() || !inputVmMemory.isInputValid() + || !inputVmDiskSize.isInputValid()) throw new NumberFormatException(); + cpu = inputVmCpu.getValue(); + memoryMb = inputVmMemory.getValue(SizeUnit.MB); + diskBytes = inputVmDiskSize.getValue(); + } catch (Exception e) { + if (!inputVmCpu.isInputValid()) + inputVmCpu.setError(getString(R.string.create_vm_error_invalid_number)); + if (!inputVmMemory.isInputValid()) + inputVmMemory.setError(getString(R.string.create_vm_error_invalid_number)); + if (!inputVmDiskSize.isInputValid()) + inputVmDiskSize.setError(getString(R.string.create_vm_error_invalid_number)); + return; + } + var password = inputVmRootPassword.getText(); + inputVmRootPassword.setError(null); + if (password.isEmpty()) { + inputVmRootPassword.setError(getString(R.string.change_password_error_empty)); + return; + } + if (!isShellSafePassword(password)) { + inputVmRootPassword.setError(getString( + R.string.change_password_error_unsafe, SHELL_SAFE_PASSWORD_SYMBOLS)); + return; + } + if (!selectedVmNetworkId.isEmpty()) { + try { + var networks = new NetworkStore(); + networks.load(this); + if (networks.findById(UUID.fromString(selectedVmNetworkId)) == null) + throw new IllegalArgumentException("network missing"); + } catch (Exception e) { + Toast.makeText( + this, R.string.linux_vm_network_unavailable, LENGTH_SHORT).show(); + setupVmNetworkDropdown(); + return; + } + } + var downloadBaseUrl = getDownloadBaseUrl(); + if (downloadBaseUrl.isEmpty()) { + if (getSelectedDlSourceKey().equals(SOURCE_CUSTOM)) + inputCustomDlUrl.setError(getString(R.string.lxc_error_custom_url)); + return; + } + inputCustomDlUrl.setError(null); + var image = selectedImage; + if (image == null) return; + var name = image.getDefaultFileName(); + var folder = pathJoin(externalPath(), "DroidVM", vmName); + var destPath = pathJoin(folder, name); + if (new File(destPath).exists()) { + inputVmName.setError(getString(R.string.import_url_error_file_exists)); + return; + } + pendingLinuxVmName = vmName; + pendingLinuxRootPassword = password; + pendingLinuxNetworkId = selectedVmNetworkId; + pendingLinuxCpu = cpu; + pendingLinuxMemoryMb = memoryMb; + pendingLinuxDiskBytes = diskBytes; + downloadName = name; + downloadFolder = folder; + var downloadUrl = pathJoin(downloadBaseUrl, image.getDownloadPath()); + notifPermission.ensureThen(() -> startDownload(downloadUrl)); + } + private void startDownload(String url) { isDownloading = true; captureSession(); @@ -709,7 +1588,7 @@ private void startDownload(String url) { // enqueue() resolves redirects (network I/O), so run it off the main thread. runOnPool(() -> { long id = DiskDownloadManager.enqueue( - this, url, LXC_USER_AGENT, folder, name, ImportLxcImagesActivity.class); + this, url, LXC_USER_AGENT, folder, name, getClass()); runOnUiThread(() -> onDownloadEnqueued(id)); }); } @@ -722,18 +1601,22 @@ private void onDownloadEnqueued(long id) { if (!isDownloading) { // Cancelled while still enqueueing. DiskDownloadManager.cancel(id); + DiskDownloadManager.release(id); return; } currentDownloadId = id; + var s = getSession(); + if (s != null) s.downloadId = id; + DiskDownloadManager.retainUntilReleased(id); DiskDownloadService.start(this, id); - if (!isDestroyed()) pollHandler.post(pollRunnable); + if (activityStarted && !isDestroyed()) pollHandler.post(pollRunnable); } /** Mirrors the download's live state into the on-screen widget. */ private final Runnable pollRunnable = new Runnable() { @Override public void run() { - if (currentDownloadId < 0) return; + if (!activityStarted || currentDownloadId < 0) return; var p = DiskDownloadManager.query(currentDownloadId); if (p == null) { cancelDownload(); // job gone (cancelled elsewhere) @@ -768,29 +1651,243 @@ private void onDownloadSucceeded() { currentDownloadId = -1; pollHandler.removeCallbacks(pollRunnable); downloadWidget.markExternalFinished(); - var result = DiskDownloadManager.getResult(id); + var result = DiskDownloadManager.consumeResult(id); if (result == null) { + clearSessionDownloadId(id); + DiskDownloadManager.release(id); finish(); return; } - Toast.makeText( - this, - getString(R.string.lxc_import_success, result.name), - LENGTH_SHORT - ).show(); + clearSessionDownloadId(id); + if (linuxVmMode) { + onLinuxDownloadSucceeded(result); + return; + } + var resetPassword = inputResetPassword.getText(); + inputResetPassword.setText(""); + var s = getSession(); + if (s != null) s.resetPassword = ""; var resultData = new Intent(); resultData.putExtra("result_disk_path", pathJoin(result.folder, result.name)); setResult(RESULT_OK, resultData); - if (result.diskId != null && DiskFormat.fromFilename(result.name) == DiskFormat.QCOW2) - startOptimize(this, result.diskId); + boolean hasPasswordReset = result.diskId != null && !resetPassword.isEmpty(); + if (hasPasswordReset) { + pendingPasswordDiskId = result.diskId; + pendingResetPassword = resetPassword; + pendingImportName = result.name; + } else { + showImportSuccess(result.name); + } + if (result.diskId != null && DiskFormat.fromFilename(result.name) == DiskFormat.QCOW2) { + // Resolve the backing chain first (may prompt once), then rewrite only when the + // compression can't boot on crosvm. A requested password reset waits for a successful + // rewrite before starting its visual operation. + var diskId = result.diskId; + var diskPath = pathJoin(result.folder, result.name); + if (!hasPasswordReset) { + BackingChainLinker.link(this, diskId, () -> + startOptimizeAfterImport(this, diskId, diskPath, this::finish)); + } else { + BackingChainLinker.link(this, diskId, () -> + startOptimizeAfterImportForResult( + this, + diskId, + diskPath, + optimizeLauncher, + this::launchPasswordAfterImport, + this::completePendingImport)); + } + return; + } + if (hasPasswordReset) { + launchPasswordAfterImport(); + return; + } + finish(); + } + + private void onLinuxDownloadSucceeded(@NonNull DiskDownloadManager.Result result) { + if (result.diskId == null) { + finishLinuxVmFlow(false); + return; + } + pendingLinuxDiskId = result.diskId; + inputVmRootPassword.setText(""); + var s = getSession(); + if (s != null) s.vmRootPassword = ""; + // Published images usually carry compressed clusters, but the VM this + // flow creates boots on crosvm, which reads only uncompressed qcow2. + // Link the chain and rewrite now, inside the wait the user already + // expects, instead of leaving it to the pre-start guard's "start + // anyway" countdown. Declining the rewrite prompt continues unconverted + // (that guard still stands); a rewrite that ran and failed aborts. + var diskId = result.diskId; + var diskPath = pathJoin(result.folder, result.name); + BackingChainLinker.link(this, diskId, () -> + startOptimizeAfterImportForResult( + this, + diskId, + diskPath, + linuxOptimizeLauncher, + this::launchLinuxResize, + this::launchLinuxResize)); + } + + private void launchLinuxResize() { + var diskId = pendingLinuxDiskId; + if (diskId == null) { + finishLinuxVmFlow(false); + return; + } + try { + var task = new JSONObject(); + task.put("action", "resize"); + task.put("size", String.valueOf(pendingLinuxDiskBytes)); + var intent = cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity.createIntent( + this, diskId, task); + intent.putExtra( + cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity.EXTRA_AUTOFINISH, + true); + linuxResizeLauncher.launch(intent); + } catch (Exception e) { + Log.e(TAG, "Failed to start Linux VM disk resize", e); + finishLinuxVmFlow(false); + } + } + + private void launchLinuxMaintenance() { + var diskId = pendingLinuxDiskId; + if (diskId == null || pendingLinuxRootPassword.isEmpty()) { + finishLinuxVmFlow(false); + return; + } + try { + var diskStore = new DiskStore(); + diskStore.load(this); + var disk = diskStore.findById(diskId); + if (disk == null) throw new IllegalStateException("Downloaded disk is not registered"); + var agentVM = new AgentVM(VMBackend.QEMU, VMHypervisor.SOFT); + agentVM.setOperationConsole("uart", "/dev/ttyAMA0"); + var password = new PasswordAction(agentVM); + password.setPassword(pendingLinuxRootPassword); + password.setChangeNormalUsers(false); + new AutoGrowAction(agentVM); + agentVM.addDisk(disk); + var intent = AgentOperationActivity.createIntent(this, agentVM); + intent.putExtra(AgentOperationActivity.EXTRA_AUTOFINISH_ON_SUCCESS, true); + pendingLinuxRootPassword = ""; + linuxMaintenanceLauncher.launch(intent); + } catch (Exception e) { + Log.e(TAG, "Failed to start Linux VM maintenance", e); + finishLinuxVmFlow(false); + } + } + + private void createPendingLinuxVm() { + var diskId = pendingLinuxDiskId; + if (diskId == null || pendingLinuxVmName.isEmpty()) { + finishLinuxVmFlow(false); + return; + } + try { + var diskStore = new DiskStore(); + diskStore.load(this); + var disk = diskStore.findById(diskId); + if (disk == null) throw new IllegalStateException("Downloaded disk is not registered"); + var config = VMConfig.createWithCustomizeDefaults(this); + config.setName(pendingLinuxVmName); + config.item.set("cpu_count", pendingLinuxCpu); + config.item.set("memory_mb", pendingLinuxMemoryMb); + var disks = DataItem.newArray(); + var diskItem = DataItem.newObject(); + diskItem.set("path", disk.getFullPath()); + diskItem.set("bus", DiskBus.VIRTIO); + diskItem.set("readonly", false); + disks.append(diskItem); + config.item.set("disks", disks); + var networks = DataItem.newArray(); + if (!pendingLinuxNetworkId.isEmpty()) { + var network = DataItem.newObject(); + network.set("network_id", pendingLinuxNetworkId); + network.set("mac_address", generateRandomMac()); + networks.append(network); + } + config.item.set("networks", networks); + var vmStore = new VMStore(); + vmStore.load(this); + vmStore.add(config); + if (!vmStore.save(this)) + throw new IllegalStateException("Failed to save VM configuration"); + var result = new Intent(); + result.putExtra("result_vm_id", config.getId().toString()); + result.putExtra("result_disk_path", disk.getFullPath()); + setResult(RESULT_OK, result); + Toast.makeText( + this, + getString(R.string.linux_vm_create_success, config.getName()), + LENGTH_SHORT + ).show(); + finishLinuxVmFlow(true); + } catch (Exception e) { + Log.e(TAG, "Failed to create Linux VM", e); + finishLinuxVmFlow(false); + } + } + + private void finishLinuxVmFlow(boolean success) { + pendingLinuxDiskId = null; + pendingLinuxVmName = ""; + pendingLinuxRootPassword = ""; + pendingLinuxNetworkId = ""; + if (!success && !isFinishing()) + Toast.makeText(this, R.string.linux_vm_create_failed, LENGTH_SHORT).show(); + finish(); + } + + private void launchPasswordAfterImport() { + var diskId = pendingPasswordDiskId; + var password = pendingResetPassword; + pendingPasswordDiskId = null; + pendingResetPassword = ""; + if (diskId == null || password.isEmpty() || isFinishing()) { + completePendingImport(); + return; + } + try { + passwordLauncher.launch( + ChangePasswordActivity.createQuickIntent(this, diskId, password)); + } catch (Exception e) { + Log.e(TAG, "Failed to start quick password change", e); + completePendingImport(); + } + } + + private void completePendingImport() { + var name = pendingImportName; + pendingPasswordDiskId = null; + pendingResetPassword = ""; + pendingImportName = ""; + if (!name.isEmpty()) showImportSuccess(name); finish(); } + private void showImportSuccess(@NonNull String name) { + Toast.makeText( + this, + getString(R.string.lxc_import_success, name), + LENGTH_SHORT + ).show(); + } + private void cancelDownload() { long id = currentDownloadId; currentDownloadId = -1; pollHandler.removeCallbacks(pollRunnable); - if (id >= 0) DiskDownloadManager.cancel(id); + if (id >= 0) { + DiskDownloadManager.cancel(id); + DiskDownloadManager.release(id); + } + clearSessionDownloadId(id); downloadWidget.markExternalCancelled(); resetAfterDownloadStop(); } @@ -799,11 +1896,21 @@ private void onDownloadFailed(@Nullable String reason) { long id = currentDownloadId; currentDownloadId = -1; pollHandler.removeCallbacks(pollRunnable); - if (id >= 0) DiskDownloadManager.cancel(id); + if (id >= 0) { + DiskDownloadManager.cancel(id); + DiskDownloadManager.release(id); + } + clearSessionDownloadId(id); downloadWidget.markExternalFailed(reason); resetAfterDownloadStop(); } + private void clearSessionDownloadId(long id) { + var s = getSession(); + if (s != null && (id < 0 || s.downloadId == id)) + sessions.remove(sessionKey(), s); + } + private void resetAfterDownloadStop() { isDownloading = false; setInputsEnabled(true); @@ -823,6 +1930,13 @@ private void setInputsEnabled(boolean enabled) { dropdownBuild.setEnabled(enabled); inputFilename.setEnabled(enabled); inputFolder.setEnabled(enabled); + inputResetPassword.setEnabled(enabled); + inputVmName.setEnabled(enabled); + inputVmCpu.setEnabled(enabled); + inputVmMemory.setEnabled(enabled); + inputVmDiskSize.setEnabled(enabled); + inputVmRootPassword.setEnabled(enabled); + dropdownVmNetwork.setEnabled(enabled); btnLoad.setEnabled(enabled); } @@ -850,17 +1964,24 @@ private void setImageSectionEnabled(boolean enabled) { } private void setOutputEnabled(boolean enabled) { + if (linuxVmMode) { + setLinuxSettingsEnabled(enabled); + return; + } float alpha = enabled ? 1.0f : 0.38f; dividerOutput.setAlpha(alpha); tvOutputHeader.setAlpha(alpha); inputFilename.setAlpha(enabled ? 1.0f : alpha); inputFolder.setAlpha(enabled ? 1.0f : alpha); + inputResetPassword.setAlpha(enabled ? 1.0f : alpha); cardInfo.setAlpha(alpha); inputFilename.setEnabled(enabled); inputFolder.setEnabled(enabled); + inputResetPassword.setEnabled(enabled); fabImport.setVisibility(enabled ? VISIBLE : GONE); if (!enabled) { inputFilename.setText(""); + inputResetPassword.setText(""); tvInfoSize.setText(""); tvInfoPath.setText(""); cardInfo.setVisibility(GONE); @@ -869,24 +1990,37 @@ private void setOutputEnabled(boolean enabled) { } } + private void setLinuxSettingsEnabled(boolean enabled) { + float alpha = enabled ? 1.0f : 0.38f; + dividerSettings.setAlpha(alpha); + tvSettingsHeader.setAlpha(alpha); + inputVmName.setAlpha(enabled ? 1.0f : alpha); + inputVmCpu.setAlpha(enabled ? 1.0f : alpha); + inputVmMemory.setAlpha(enabled ? 1.0f : alpha); + inputVmDiskSize.setAlpha(enabled ? 1.0f : alpha); + inputVmRootPassword.setAlpha(enabled ? 1.0f : alpha); + dropdownVmNetwork.setAlpha(enabled ? 1.0f : alpha); + inputVmName.setEnabled(enabled); + inputVmCpu.setEnabled(enabled); + inputVmMemory.setEnabled(enabled); + inputVmDiskSize.setEnabled(enabled); + inputVmRootPassword.setEnabled(enabled); + dropdownVmNetwork.setEnabled(enabled); + fabImport.setVisibility(enabled ? VISIBLE : GONE); + if (!enabled) { + inputVmName.setText(""); + inputVmRootPassword.setText(""); + kernelAnalysis.setVisibility(GONE); + selectedImage = null; + } + } + private String getSelectedMetaSourceKey() { - if (metaSourceLabels == null || metaSourceKeys == null) - return isClassFunApiAvailable ? "classfun" : "official"; - var label = dropdownMetaSource.getText(); - for (int i = 0; i < metaSourceLabels.length; i++) - if (metaSourceLabels[i].equals(label)) - return metaSourceKeys[i]; - return isClassFunApiAvailable ? "classfun" : "official"; + return selectedMetaSourceKey; } private String getSelectedDlSourceKey() { - if (dlSourceLabels == null || dlSourceKeys == null) - return "official"; - var label = dropdownDlSource.getText(); - for (int i = 0; i < dlSourceLabels.length; i++) - if (dlSourceLabels[i].equals(label)) - return dlSourceKeys[i]; - return "official"; + return selectedDlSourceKey; } private void setDropdownItems(@NonNull DropdownRowWidget dropdown, String[] items, int icon) { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/LxcImage.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/LxcImage.java index 6054d46b..28a17a62 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/LxcImage.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/LxcImage.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.ui.disk.lxc; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/LxcImageParser.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/LxcImageParser.java index c5159e51..0f2392c0 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/LxcImageParser.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/lxc/LxcImageParser.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.ui.disk.lxc; import static android.os.Build.SUPPORTED_ABIS; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java index 6edf43ac..8ca9069d 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/DiskOperationActivity.java @@ -1,10 +1,12 @@ +// 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.ui.disk.operation; import static android.view.View.GONE; import static android.view.View.VISIBLE; import static cn.classfun.droidvm.lib.utils.FileUtils.findExecute; -import static cn.classfun.droidvm.lib.utils.ImageUtils.getImageInfo; -import static cn.classfun.droidvm.lib.utils.ImageUtils.hasCompressedClusters; +import static cn.classfun.droidvm.lib.utils.ImageUtils.hasBackingFile; import static cn.classfun.droidvm.lib.utils.ProcessUtils.SIGHUP; import static cn.classfun.droidvm.lib.utils.ProcessUtils.shellKillProcess; import static cn.classfun.droidvm.lib.utils.StringUtils.basename; @@ -23,7 +25,9 @@ import android.widget.TextView; import androidx.activity.OnBackPressedCallback; +import androidx.activity.result.ActivityResultLauncher; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import com.google.android.material.appbar.MaterialToolbar; @@ -31,8 +35,6 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.termux.terminal.TerminalSession; import com.termux.terminal.TerminalSessionClient; -import com.termux.view.TerminalView; -import com.termux.view.TerminalViewClient; import org.json.JSONObject; @@ -41,9 +43,15 @@ import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.store.disk.DiskConfig; import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.utils.RunUtils; import cn.classfun.droidvm.lib.ui.termux.SimpleTerminalSessionClient; -import cn.classfun.droidvm.lib.ui.termux.TerminalFonts; -import cn.classfun.droidvm.lib.ui.termux.SimpleTerminalViewClient; +import cn.classfun.droidvm.lib.ui.termux.TerminalPanelView; +import cn.classfun.droidvm.ui.disk.create.DiskCompress; +import cn.classfun.droidvm.lib.store.vm.VMStore; +import cn.classfun.droidvm.ui.disk.action.DiskDependencyUpdater; +import cn.classfun.droidvm.ui.disk.tree.AttachmentCursors; +import cn.classfun.droidvm.ui.disk.tree.CursorPlan; +import cn.classfun.droidvm.ui.disk.tree.TreeShape; import cn.classfun.droidvm.ui.main.settings.MainSettingsFragment; public final class DiskOperationActivity extends AppCompatActivity { @@ -55,8 +63,10 @@ public final class DiskOperationActivity extends AppCompatActivity { public static final String EXTRA_DISK_NAME = "disk_name"; /** On success, {@code setResult(RESULT_OK)} and finish so a launcher can chain. */ public static final String EXTRA_AUTOFINISH = "autofinish"; + /** Explicit in-app activity to launch only after this disk operation succeeds. */ + public static final String EXTRA_SUCCESS_INTENT = "success_intent"; private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private TerminalView terminalView; + private TerminalPanelView terminalPanel; private ProgressBar progressSpinner; private ImageView ivStatus; private TextView tvFilename; @@ -65,7 +75,9 @@ public final class DiskOperationActivity extends AppCompatActivity { private MaterialToolbar toolbar; private TerminalSession session; private boolean finished = false; + private boolean postProcessing = false; private boolean autoFinish = false; + private Intent successIntent = null; private String outputPath = null; private String taskAction = null; private DiskStore diskStore = null; @@ -75,8 +87,7 @@ public final class DiskOperationActivity extends AppCompatActivity { @Override public void onTextChanged(@NonNull TerminalSession s) { mainHandler.post(() -> { - if (terminalView != null) - terminalView.onScreenUpdated(); + if (terminalPanel != null) terminalPanel.refresh(); }); } @@ -86,9 +97,6 @@ public void onSessionFinished(@NonNull TerminalSession s) { } }; - private final TerminalViewClient viewClient = new SimpleTerminalViewClient() { - }; - @NonNull public static Intent createIntent( @NonNull Context context, @@ -129,19 +137,81 @@ public static Intent optimizeForResultIntent( return intent; } - public static void startOptimize( - @NonNull Context context, - @NonNull UUID diskId + /** + * Post-import hook: optimize only when the imported image's compression isn't in + * {@link DiskCompress#CROSVM_SUPPORTED} (an image crosvm already boots needs no rewrite). + * The compression check runs off the main thread; {@code done} always runs on the main + * thread - after launching the optimize, after skipping it, or on prompt cancel. + */ + public static void startOptimizeAfterImport( + @NonNull android.app.Activity activity, + @NonNull UUID diskId, + @NonNull String path, + @NonNull Runnable done ) { - try { - var obj = new JSONObject(); - obj.put("action", "convert"); - obj.put("keep_compress", true); // preserve compression when optimizing - var intent = createIntent(context, diskId, obj); - context.startActivity(intent); - } catch (Exception e) { - Log.e(TAG, "Failed to start optimize activity", e); - } + startOptimizeAfterImportImpl(activity, diskId, path, null, done, done); + } + + /** + * Result-aware variant for callers that must continue only after optimization succeeds. + * {@code onSkipped} runs when the image already needs no rewrite; an optimization that is + * required is launched through {@code launcher} and reports its outcome there. Cancelling the + * compression prompt or failing to launch calls {@code onCancelled}. + */ + public static void startOptimizeAfterImportForResult( + @NonNull android.app.Activity activity, + @NonNull UUID diskId, + @NonNull String path, + @NonNull ActivityResultLauncher launcher, + @NonNull Runnable onSkipped, + @NonNull Runnable onCancelled + ) { + startOptimizeAfterImportImpl( + activity, diskId, path, launcher, onSkipped, onCancelled); + } + + private static void startOptimizeAfterImportImpl( + @NonNull android.app.Activity activity, + @NonNull UUID diskId, + @NonNull String path, + @Nullable ActivityResultLauncher launcher, + @NonNull Runnable onSkipped, + @NonNull Runnable onCancelled + ) { + runOnPool(() -> { + var supported = DiskCompress.detect(path).isCrosvmSupported(); + // An imported overlay is skipped outright: it is mostly a header (nothing worth + // rewriting), and its chain gets checked by the pre-start guard instead. + var isOverlay = hasBackingFile(path); + activity.runOnUiThread(() -> { + if (activity.isFinishing()) { + onCancelled.run(); + return; + } + if (supported || isOverlay) { + onSkipped.run(); + return; + } + OptimizeCompression.resolve(activity, onCancelled, compress -> { + try { + var obj = new JSONObject(); + obj.put("action", "convert"); + obj.put("compress", compress.value()); + var intent = createIntent(activity, diskId, obj); + if (launcher == null) { + activity.startActivity(intent); + onSkipped.run(); + } else { + intent.putExtra(EXTRA_AUTOFINISH, true); + launcher.launch(intent); + } + } catch (Exception e) { + Log.e(TAG, "Failed to start optimize activity", e); + onCancelled.run(); + } + }); + }); + }); } public static void startConvert( @@ -174,8 +244,8 @@ protected void onCreate(Bundle savedInstanceState) { tvFilename = findViewById(R.id.tv_filename); tvStatus = findViewById(R.id.tv_status); btnCancel = findViewById(R.id.btn_cancel); - terminalView = findViewById(R.id.terminal_view); - terminalView.setTerminalViewClient(viewClient); + terminalPanel = findViewById(R.id.terminal_panel); + terminalPanel.setInteractive(false); btnCancel.setOnClickListener(v -> confirmCancel()); initialize(); } @@ -193,6 +263,7 @@ public void handleOnBackPressed() { var diskIdStr = intent.getStringExtra(EXTRA_DISK_ID); var taskJsonStr = intent.getStringExtra(EXTRA_TASK_JSON); autoFinish = intent.getBooleanExtra(EXTRA_AUTOFINISH, false); + successIntent = intent.getParcelableExtra(EXTRA_SUCCESS_INTENT, Intent.class); if (taskJsonStr == null) { Log.e(TAG, "Missing task JSON"); finish(); @@ -228,7 +299,6 @@ public void handleOnBackPressed() { final String cmd; try { var task = new JSONObject(taskJsonStr); - applyKeepCompress(getApplicationContext(), task, diskPath); var gen = new ImageCommandGenerate(diskStore); gen.setCpuAffinity( MainSettingsFragment.getQemuImgCpuAffinity(getApplicationContext())); @@ -246,38 +316,125 @@ public void handleOnBackPressed() { } /** - * When a task opts into keep-compress (the user-facing "optimize", not the - * pre-start decompress in {@link #optimizeForResultIntent}), re-compress the - * rewritten image with the algorithm the source uses instead of rewriting it - * uncompressed. Whether the source is compressed at all is decided by - * qemu-img's real {@code compressed-clusters} count - * ({@code ImageUtils.hasCompressedClusters}); the qcow2 header's - * {@code compression-type} is only read to pick zlib vs - * zstd, because that header field reads "zlib" for every v3 image and so - * cannot on its own distinguish an uncompressed disk from a compressed one. - * An uncompressed source (raw, or a v3 qcow2 with no compressed data) is left - * alone. An explicit {@code compress} in the task always wins, the user can - * turn this off globally in settings, and any detection failure falls through - * to the default (uncompressed) rewrite. + * After a successful {@code qemu-img commit}: the base's logical content now equals the + * overlay's, so re-pointing the overlay's children and VM attachments at the base is a + * lossless swap. Order matters only in that the overlay is deleted LAST - until then both + * "points at overlay" and "points at base" are valid views, so a failure at any step leaves + * a consistent, recoverable state. */ - private static void applyKeepCompress( - @NonNull Context ctx, @NonNull JSONObject task, @NonNull String path) { - if (!task.optBoolean("keep_compress", false) || task.has("compress")) - return; - if (!MainSettingsFragment.isKeepCompressOnOptimizeEnabled(ctx)) - return; + private boolean finishCommit() { + try { + var store = new DiskStore(); + if (!store.load(this)) { + Log.e(TAG, "commit finished but disk registry could not be loaded; keeping overlay"); + return false; + } + var overlay = store.findById(diskConfig.getId()); + if (overlay == null) return false; + var parent = store.parentOf(overlay); + if (parent == null) { + Log.w(TAG, "commit finished but overlay has no registered parent"); + return false; + } + var overlayPath = overlay.getFullPath(); + var parentPath = parent.getFullPath(); + var parentFormat = detectFormat(parentPath); + // Children of the committed overlay re-base onto the (now content-identical) + // parent: header-only rewrite, then the registry link. A partial failure is still + // consistent: already-moved children point at the parent and the overlay remains for + // children that were not moved. + for (var child : store.childrenOf(overlay.getId())) { + var result = RunUtils.runList( + findQemuImg(), "rebase", "-u", + "-b", parentPath, "-F", parentFormat, child.getFullPath()); + if (!result.isSuccess()) { + result.printLog(TAG); + if (!store.save(this)) + Log.e(TAG, "Failed to persist completed child rebases"); + Log.e(TAG, fmt( + "Keeping committed overlay after child rebase failure: %s", + child.getFullPath())); + return false; + } + child.setParentId(parent.getId()); + } + // Persist child links before changing attachments or deleting the overlay file. + if (!store.save(this)) { + Log.e(TAG, "Keeping committed overlay: failed to save child links"); + return false; + } + // Only slots pointing directly at the merged overlay move to its parent (the + // children were re-linked above, so their slots stay exactly where they are). A slot + // landing on a base that still has overlays, or joining another VM on the same + // disk, becomes read-only - the same rule the branch panel showed beforehand. + var vmStore = new VMStore(); + vmStore.load(vmStore, this); + var shape = TreeShape.of(store); + var cursors = AttachmentCursors.collectPersisted( + store, vmStore, shape.familyOf(overlay.getId()), null, java.util.List.of()); + var plan = CursorPlan.reconcile(cursors, java.util.List.of(), + shape, shape.withMerged(overlay.getId())); + if (!DiskDependencyUpdater.applyPlan(this, plan)) { + Log.e(TAG, "Keeping committed overlay: failed to save VM attachments"); + return false; + } + store.removeById(overlay.getId()); + if (!store.save(this)) { + Log.e(TAG, "Keeping committed overlay: failed to remove registry entry"); + return false; + } + var removed = RunUtils.runList("rm", "-f", overlayPath); + if (!removed.isSuccess()) { + removed.printLog(TAG); + return false; + } + return true; + } catch (Exception e) { + Log.e(TAG, "commit follow-up failed", e); + return false; + } + } + + /** + * After a successful flatten the replacement image uses the same path and contains the whole + * backing-chain view. Child backing headers and every VM slot therefore remain valid and must + * not move; only this image's parent registry link is cleared. + */ + private boolean finishFlatten() { try { - if (!hasCompressedClusters(path)) - return; // nothing actually compressed -- rewrite uncompressed - var info = getImageInfo(path); - var fmtSpecific = info.optJSONObject("format-specific"); - var data = fmtSpecific == null ? null : fmtSpecific.optJSONObject("data"); - var type = data == null ? "" : data.optString("compression-type", ""); - // qemu compression-type zstd stays zstd; zlib (and anything else) -> deflate. - task.put("compress", "zstd".equals(type) ? "zstd" : "deflate"); + var store = new DiskStore(); + if (!store.load(this)) { + Log.e(TAG, "flatten finished but disk registry could not be loaded"); + return false; + } + var overlay = store.findById(diskConfig.getId()); + if (overlay == null) return false; + overlay.setParentId(null); + if (!store.save(this)) { + Log.e(TAG, "flatten finished but standalone registry link could not be saved"); + return false; + } + return true; } catch (Exception e) { - Log.w(TAG, "keep-compress detection failed", e); + Log.e(TAG, "flatten follow-up failed", e); + return false; + } + } + + @NonNull + private static String detectFormat(@NonNull String path) { + try { + var f = cn.classfun.droidvm.lib.utils.ImageUtils.getImageInfo(path) + .optString("format", ""); + if (!f.isEmpty()) return f; + } catch (Exception ignored) { } + return "qcow2"; + } + + @NonNull + private static String findQemuImg() { + return cn.classfun.droidvm.lib.utils.AssetUtils.getPrebuiltBinaryPath("qemu-img"); } private void startTerminalSession(String cmd) { @@ -290,19 +447,27 @@ private void startTerminalSession(String cmd) { fmt("HOME=%s", cwd), }; session = new TerminalSession(shell, cwd, args, env, null, sessionClient); - float density = getResources().getDisplayMetrics().density; - terminalView.setTextSize((int) (10 * density)); - TerminalFonts.apply(terminalView); - terminalView.attachSession(session); + terminalPanel.attachSession(session); } private void onProcessFinished() { if (finished) return; finished = true; int exitCode = session == null ? -1 : session.getExitStatus(); + // Overlay-tree success includes its persisted relationship follow-up. Keep the progress + // UI up until that finishes; never report success while VMStore/DiskStore is still stale. + if (exitCode == 0 && diskConfig != null && "commit".equals(taskAction)) { + startTreePostProcessing(this::finishCommit); + return; + } else if (exitCode == 0 && diskConfig != null && "flatten".equals(taskAction)) { + startTreePostProcessing(this::finishFlatten); + return; + } // Path mode (no registered DiskConfig) is an in-place op, so there is - // nothing to persist -- skip the store update. - if (exitCode == 0 && outputPath != null && diskConfig != null) { + // nothing to persist -- skip the store update. commit/flatten did their own registry + // work above (their outputPath equals the disk path; nothing to rename either). + if (exitCode == 0 && outputPath != null && diskConfig != null + && !"commit".equals(taskAction) && !"flatten".equals(taskAction)) { if (taskAction.equals("clone")) { var cloned = new DiskConfig(); if (outputPath.contains("/")) { @@ -323,6 +488,11 @@ private void onProcessFinished() { } diskStore.save(this); } + if (exitCode == 0 && successIntent != null) { + startActivity(successIntent); + finish(); + return; + } // Chained convert (e.g. pre-start decompress): hand control back to the // launcher, which starts the VM. No success screen -- the start is the // feedback. @@ -343,6 +513,28 @@ private void onProcessFinished() { } } + private void startTreePostProcessing(@NonNull java.util.function.BooleanSupplier operation) { + postProcessing = true; + btnCancel.setVisibility(GONE); + tvStatus.setText(R.string.disk_operation_finalizing); + runOnPool(() -> { + boolean success = operation.getAsBoolean(); + runOnUiThread(() -> { + if (isFinishing()) return; + postProcessing = false; + progressSpinner.setVisibility(GONE); + ivStatus.setVisibility(VISIBLE); + if (success) { + ivStatus.setImageResource(R.drawable.ic_large_success); + tvStatus.setText(R.string.disk_operation_success); + } else { + ivStatus.setImageResource(R.drawable.ic_large_error); + tvStatus.setText(R.string.disk_operation_dependency_failed); + } + }); + }); + } + private void showFailed(String message) { finished = true; progressSpinner.setVisibility(GONE); @@ -366,6 +558,7 @@ private void confirmCancel() { } private void confirmFinish() { + if (postProcessing) return; if (finished) { finish(); return; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java index 825285c4..20549f0e 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/ImageCommandGenerate.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.ui.disk.operation; import static cn.classfun.droidvm.lib.utils.AssetUtils.getPrebuiltBinaryPath; @@ -72,8 +75,9 @@ public String buildCommand(@NonNull JSONObject task, String diskPath) throws JSO appendAction(); sb.append("; then "); if (useTempPath) { - sb.append(fmt("rm -vf %s; ", realPath)); - sb.append(fmt("mv -v %s %s; ", tmpPath, realPath)); + // tmp lives beside the destination, so mv replaces it with one same-filesystem + // rename. The old image remains intact until the new image is complete. + sb.append(fmt("mv -vf %s %s; ", tmpPath, realPath)); } else if (!diskPath.equals(outputPath) && !action.equals("clone")) { sb.append(fmt("rm -vf %s; ", eDiskPath)); } @@ -101,11 +105,28 @@ private void appendAction() throws JSONException { case "convert": appendConvert(); break; + case "commit": + appendCommit(); + break; + case "flatten": + appendFlatten(); + break; default: throw new RuntimeException(fmt("Unknown action: %s", action)); } } + /** Merge the overlay's changes down into its backing file (in place, both files). */ + private void appendCommit() { + sb.append(" commit -p ").append(eDiskPath); + } + + /** Copy the complete backing-chain view, then atomically replace the overlay on success. */ + private void appendFlatten() throws JSONException { + task.put("drop_backing", true); + appendConvert(); + } + private void appendClone() throws JSONException { var pv = getPrebuiltBinaryPath("pv"); sb.append(escapedString(pv)); @@ -135,6 +156,17 @@ private void appendConvert() throws JSONException { task.put("format", format); } else throw new RuntimeException("No format specified in task or image info"); sb.append(" --target-format ").append(format); + // convert reads through the whole backing chain, so rewriting an overlay without + // re-declaring its backing silently flattens it into a standalone full image. Unless the + // task names a backing itself (or asks to drop it), carry the source's backing over. + if (!task.has("backing_id") && !task.has("backing_path") + && !task.optBoolean("drop_backing", false) + && info.has("backing-filename") + && "qcow2".equalsIgnoreCase(format)) { + var backing = info.optString("full-backing-filename", + info.getString("backing-filename")); + if (!backing.isEmpty()) task.put("backing_path", backing); + } if (task.has("output")) { outputPath = task.getString("output"); // In-place re-compress (output == source): writing straight to the diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/OptimizeCompression.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/OptimizeCompression.java new file mode 100644 index 00000000..336bf561 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/operation/OptimizeCompression.java @@ -0,0 +1,82 @@ +// 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.ui.disk.operation; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.RadioGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import com.google.android.material.checkbox.MaterialCheckBox; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.radiobutton.MaterialRadioButton; + +import java.util.function.Consumer; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.ui.disk.create.DiskCompress; +import cn.classfun.droidvm.ui.main.settings.MainSettingsFragment; + +/** + * Resolves the compression a disk optimize should target: the preferred-compression setting + * when it names one, otherwise (the "ask every time" default) a prompt whose "remember" + * checkbox writes the choice back to the setting. Only compressions crosvm can boot from + * ({@link DiskCompress#CROSVM_SUPPORTED}) are offered - the choices grow automatically as + * that set does. Call on the main thread with a UI context. + */ +public final class OptimizeCompression { + private OptimizeCompression() { + } + + /** Display label: the shared enum label, except NONE which reads "uncompressed" here. */ + @StringRes + public static int labelOf(@NonNull DiskCompress compress) { + return compress == DiskCompress.NONE + ? R.string.disk_compress_none : compress.getStringId(); + } + + public static void resolve( + @NonNull Context context, + @NonNull Runnable onCancel, + @NonNull Consumer onChosen + ) { + var preferred = DiskCompress.fromValue( + MainSettingsFragment.getOptimizeCompression(context)); + if (preferred != null && preferred.isCrosvmSupported()) { + onChosen.accept(preferred); + return; + } + var view = LayoutInflater.from(context).inflate( + R.layout.dialog_optimize_compress, null); + RadioGroup group = view.findViewById(R.id.compress_group); + MaterialCheckBox remember = view.findViewById(R.id.compress_remember); + for (var compress : DiskCompress.CROSVM_SUPPORTED) { + var radio = new MaterialRadioButton(context); + radio.setId(View.generateViewId()); + radio.setTag(compress); + radio.setText(labelOf(compress)); + group.addView(radio); + } + // First (and today only) option pre-selected. + if (group.getChildCount() > 0) + group.check(group.getChildAt(0).getId()); + new MaterialAlertDialogBuilder(context) + .setTitle(R.string.settings_optimize_compression_title) + .setView(view) + .setNegativeButton(android.R.string.cancel, (d, w) -> onCancel.run()) + .setOnCancelListener(d -> onCancel.run()) + .setPositiveButton(android.R.string.ok, (d, w) -> { + var checked = group.findViewById(group.getCheckedRadioButtonId()); + var chosen = checked == null + ? DiskCompress.NONE : (DiskCompress) checked.getTag(); + if (remember.isChecked()) + MainSettingsFragment.setOptimizeCompression(context, chosen.value()); + onChosen.accept(chosen); + }) + .show(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/AttachmentCursor.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/AttachmentCursor.java new file mode 100644 index 00000000..ced9a528 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/AttachmentCursor.java @@ -0,0 +1,114 @@ +// 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.ui.disk.tree; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.UUID; + +/** + * Where one VM disk slot sits on an overlay tree. Every slot that points into a family has one; + * tree operations move them (a deleted node's cursors climb to the nearest surviving ancestor, + * a leaf's writable cursors follow its first overlay down) and the rules about which ones may + * move silently, which must be announced, and which must not move at all hang off {@link #kind} + * and {@link #pinned}. + */ +public final class AttachmentCursor { + public enum Kind { + /** The editor row this panel was opened from. In memory; applied when the panel closes. */ + ACTIVE, + /** Another unsaved row of the same editor. In memory; follows silently. */ + EDITOR, + /** + * A persisted slot of the VM being edited. Rewritten on disk like PERSISTED (a discarded + * edit must not leave it dangling) but never announced: the editor's rows shadow it. + */ + SHADOW, + /** A persisted slot of any other VM. Rewritten on disk and announced before it happens. */ + PERSISTED, + } + + @NonNull + public final Kind kind; + /** Null for a VM that has never been saved. */ + @Nullable + public final UUID vmId; + @NonNull + public final String vmName; + /** Index in the VM's disk list (or in the editor's rows). */ + public final int slot; + /** Null once the whole tree under the cursor is gone. */ + @Nullable + public final UUID nodeId; + @Nullable + public final String path; + public final boolean readonly; + /** The VM is not stopped: the slot's file is open, so the cursor must not change. */ + public final boolean pinned; + + public AttachmentCursor( // arity-ok: a value object; these parameters are its fields + @NonNull Kind kind, + @Nullable UUID vmId, + @NonNull String vmName, + int slot, + @Nullable UUID nodeId, + @Nullable String path, + boolean readonly, + boolean pinned + ) { + this.kind = kind; + this.vmId = vmId; + this.vmName = vmName; + this.slot = slot; + this.nodeId = nodeId; + this.path = path; + this.readonly = readonly; + this.pinned = pinned; + } + + /** The same slot at another position. */ + @NonNull + public AttachmentCursor at(@Nullable UUID nodeId, @Nullable String path, boolean readonly) { + return new AttachmentCursor(kind, vmId, vmName, slot, nodeId, path, readonly, pinned); + } + + /** The same cursor with its VM's run state re-read. */ + @NonNull + public AttachmentCursor withPinned(boolean pinned) { + return new AttachmentCursor(kind, vmId, vmName, slot, nodeId, path, readonly, pinned); + } + + /** In-memory editor rows, applied by the editor when the panel closes. */ + public boolean isLive() { + return kind == Kind.ACTIVE || kind == Kind.EDITOR; + } + + /** Written to the VM store by the operation itself. */ + public boolean isPersisted() { + return kind == Kind.SHADOW || kind == Kind.PERSISTED; + } + + /** Listed in the confirmation before the operation runs. */ + public boolean isAnnounced() { + return kind == Kind.PERSISTED; + } + + /** + * Whether this cursor counts towards "two attachments on one disk". A shadow is the stale + * twin of an editor row and would double-count it. + */ + public boolean countsForSharing() { + return kind != Kind.SHADOW; + } + + @NonNull + @Override + public String toString() { + return fmt("%s:%s#%d@%s%s%s", kind, vmName, slot, path == null ? "-" : path, + readonly ? " ro" : " rw", pinned ? " pinned" : ""); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/AttachmentCursors.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/AttachmentCursors.java new file mode 100644 index 00000000..ec43920e --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/AttachmentCursors.java @@ -0,0 +1,156 @@ +// 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.ui.disk.tree; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.store.vm.VMConfig; +import cn.classfun.droidvm.lib.store.vm.VMStore; + +/** Builds the cursor set of one family from the stores plus, optionally, an editor's rows. */ +public final class AttachmentCursors { + /** The disk rows of a VM editor as they are right now, unsaved. */ + public static final class LiveRows { + /** Null for a VM that has never been saved. */ + @Nullable + public final UUID vmId; + @NonNull + public final String vmName; + @NonNull + public final List paths; + @NonNull + public final List readonly; + /** Index of the row the panel was opened from. */ + public final int active; + + public LiveRows( + @Nullable UUID vmId, + @NonNull String vmName, + @NonNull List paths, + @NonNull List readonly, + int active + ) { + this.vmId = vmId; + this.vmName = vmName; + this.paths = paths; + this.readonly = readonly; + this.active = active; + } + } + + private AttachmentCursors() { + } + + /** Live rows first, then every persisted slot; see the two halves below. */ + @NonNull + public static List collect( + @NonNull DiskStore disks, + @NonNull VMStore vms, + @NonNull Set family, + @Nullable LiveRows live, + @NonNull Collection inUseVmNames + ) { + var out = new ArrayList(); + if (live != null) out.addAll(collectLive(disks, family, live, inUseVmNames)); + out.addAll(collectPersisted(disks, vms, family, + live == null ? null : live.vmId, inUseVmNames)); + return out; + } + + /** The editor's rows that point at a disk in {@code family}: one ACTIVE, the rest EDITOR. */ + @NonNull + public static List collectLive( + @NonNull DiskStore disks, + @NonNull Set family, + @NonNull LiveRows live, + @NonNull Collection inUseVmNames + ) { + var out = new ArrayList(); + boolean pinned = live.vmId != null && inUseVmNames.contains(live.vmName); + for (int i = 0; i < live.paths.size(); i++) { + var cfg = disks.findByPath(live.paths.get(i)); + if (cfg == null || !family.contains(cfg.getId())) continue; + var kind = i == live.active + ? AttachmentCursor.Kind.ACTIVE : AttachmentCursor.Kind.EDITOR; + out.add(new AttachmentCursor(kind, live.vmId, live.vmName, i, cfg.getId(), + cfg.getFullPath(), live.readonly.get(i), pinned)); + } + return out; + } + + /** + * Every saved slot pointing at a disk in {@code family}. Slots of {@code editingVmId} become + * SHADOW (an editor's rows stand in for them); all others PERSISTED. {@code inUseVmNames} + * marks cursors pinned. + */ + @NonNull + public static List collectPersisted( + @NonNull DiskStore disks, + @NonNull VMStore vms, + @NonNull Set family, + @Nullable UUID editingVmId, + @NonNull Collection inUseVmNames + ) { + var out = new ArrayList(); + for (int v = 0; v < vms.size(); v++) { + var vm = vms.get(v); + var kind = editingVmId != null && editingVmId.equals(vm.getId()) + ? AttachmentCursor.Kind.SHADOW : AttachmentCursor.Kind.PERSISTED; + boolean pinned = inUseVmNames.contains(vm.getName()); + var slots = diskSlots(vm); + for (int i = 0; i < slots.size(); i++) { + var slot = slots.get(i); + var path = slot.optString("path", ""); + if (path.isEmpty()) continue; + var cfg = disks.findByPath(path); + if (cfg == null || !family.contains(cfg.getId())) continue; + out.add(new AttachmentCursor(kind, vm.getId(), vm.getName(), i, cfg.getId(), + cfg.getFullPath(), slot.optBoolean("readonly", false), pinned)); + } + } + return out; + } + + /** Names of all VMs in the store (plus {@code extra}), the candidates for one in-use query. */ + @NonNull + public static List allVmNames(@NonNull VMStore vms, @Nullable String extra) { + var names = new ArrayList(); + for (int i = 0; i < vms.size(); i++) names.add(vms.get(i).getName()); + if (extra != null && !extra.isEmpty() && !names.contains(extra)) names.add(extra); + return names; + } + + /** Distinct VM names among {@code cursors}, in first-seen order. */ + @NonNull + public static List vmNames(@NonNull Collection cursors) { + var names = new LinkedHashSet(); + for (var c : cursors) names.add(c.vmName); + return new ArrayList<>(names); + } + + /** Distinct names of the VMs whose cursors are pinned. */ + @NonNull + public static List pinnedVmNames(@NonNull Collection cursors) { + var pinned = new ArrayList(); + for (var c : cursors) if (c.pinned) pinned.add(c); + return vmNames(pinned); + } + + @NonNull + public static List diskSlots(@NonNull VMConfig vm) { + var disks = vm.item.opt("disks", null); + if (disks == null || !disks.is(DataItem.Type.ARRAY)) return List.of(); + return disks.asArray(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/CursorPlan.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/CursorPlan.java new file mode 100644 index 00000000..a86af61d --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/CursorPlan.java @@ -0,0 +1,182 @@ +// 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.ui.disk.tree; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; + +/** + * Where every attachment cursor ends up when the tree goes from one {@link TreeShape} to + * another. Pure: the same function predicts an operation (before it runs, for the confirmation + * and the persisted rewrite) and reconciles the editor's in-memory cursors after any refresh. + * + *

    Rules, in order: + *

      + *
    1. a cursor whose node survives stays - unless the node was a leaf that just grew its first + * overlay, in which case a writable cursor follows that overlay down (that is how "take a + * snapshot, keep going" feels), while a read-only one stays on the now-locked base;
    2. + *
    3. a cursor whose node is gone climbs the OLD ancestor chain to the nearest survivor + * (subtree delete: the parent; merge: the base), or is cleared when there is none;
    4. + *
    5. a cursor landing on a node with children, or on a node that another cursor also holds, + * is forced read-only - a base under overlays must not be written, and neither may a + * disk two writers share. Forcing only ever adds read-only; it never removes it.
    6. + *
    + * A pinned cursor (its VM is not stopped) must not change at all; one that would is reported in + * {@link #refused} and left untouched, and callers refuse the whole operation. + */ +public final class CursorPlan { + public static final class Change { + @NonNull + public final AttachmentCursor from; + @NonNull + public final AttachmentCursor to; + + public Change(@NonNull AttachmentCursor from, @NonNull AttachmentCursor to) { + this.from = from; + this.to = to; + } + + public boolean moved() { + return !Objects.equals(from.nodeId, to.nodeId); + } + + public boolean cleared() { + return to.nodeId == null; + } + + public boolean readonlyForced() { + return to.readonly && !from.readonly; + } + } + + /** Every input cursor at its new position (refused ones unchanged), input order kept. */ + @NonNull + public final List cursors = new ArrayList<>(); + /** The cursors that changed position or read-only state. */ + @NonNull + public final List changes = new ArrayList<>(); + /** Pinned cursors the operation would have changed; non-empty means "refuse". */ + @NonNull + public final List refused = new ArrayList<>(); + + private CursorPlan() { + } + + public boolean isRefused() { + return !refused.isEmpty(); + } + + /** Changes the operation writes to the VM store itself (shadow and other-VM slots). */ + @NonNull + public List persistedChanges() { + var out = new ArrayList(); + for (var c : changes) if (c.from.isPersisted()) out.add(c); + return out; + } + + /** Changes that must be stated in the confirmation (other VMs' slots). */ + @NonNull + public List announcedChanges() { + var out = new ArrayList(); + for (var c : changes) if (c.from.isAnnounced()) out.add(c); + return out; + } + + /** Changes the editor applies to its own rows when the panel closes. */ + @NonNull + public List liveChanges() { + var out = new ArrayList(); + for (var c : changes) if (c.from.isLive()) out.add(c); + return out; + } + + /** + * @param moving cursors to reposition + * @param fixed cursors already known to be correct for {@code after} (e.g. persisted slots + * re-read from the store after an operation); they only count towards sharing + * @param before the shape the moving cursors refer to + * @param after the shape they must refer to afterwards + */ + @NonNull + public static CursorPlan reconcile( + @NonNull List moving, + @NonNull List fixed, + @NonNull TreeShape before, + @NonNull TreeShape after + ) { + var plan = new CursorPlan(); + // A node that appeared under a surviving one is a freshly created overlay; the first + // one per parent is where that parent's writable cursors go (rule 1). + var createdUnder = new HashMap(); + for (var id : after.nodes()) { + if (before.contains(id)) continue; + var parent = after.parentOf(id); + if (parent != null && before.contains(parent)) createdUnder.putIfAbsent(parent, id); + } + + var targets = new ArrayList(moving.size()); + for (var c : moving) targets.add(targetOf(c, before, after, createdUnder)); + + var holders = new HashMap(); + for (int i = 0; i < moving.size(); i++) + if (targets.get(i) != null && moving.get(i).countsForSharing()) + holders.merge(targets.get(i), 1, Integer::sum); + for (var f : fixed) + if (f.nodeId != null && f.countsForSharing()) + holders.merge(f.nodeId, 1, Integer::sum); + + for (int i = 0; i < moving.size(); i++) { + var from = moving.get(i); + var target = targets.get(i); + AttachmentCursor to; + if (target == null) { + to = from.at(null, null, from.readonly); + } else { + boolean forced = after.hasChildren(target) + || holders.getOrDefault(target, 0) >= 2; + to = from.at(target, after.pathOf(target), from.readonly || forced); + } + boolean changed = !Objects.equals(from.nodeId, to.nodeId) + || from.readonly != to.readonly; + if (changed && from.pinned) { + plan.refused.add(from); + plan.cursors.add(from); + continue; + } + plan.cursors.add(to); + if (changed) plan.changes.add(new Change(from, to)); + } + return plan; + } + + @Nullable + private static UUID targetOf( + @NonNull AttachmentCursor c, + @NonNull TreeShape before, + @NonNull TreeShape after, + @NonNull Map createdUnder + ) { + if (c.nodeId == null) return null; + if (after.contains(c.nodeId)) { + var child = createdUnder.get(c.nodeId); + if (child != null && !before.hasChildren(c.nodeId) && !c.readonly) return child; + return c.nodeId; + } + var seen = new HashSet(); + var cur = before.parentOf(c.nodeId); + while (cur != null && seen.add(cur)) { + if (after.contains(cur)) return cur; + cur = before.parentOf(cur); + } + return null; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/CursorPlanText.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/CursorPlanText.java new file mode 100644 index 00000000..2336fb41 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/CursorPlanText.java @@ -0,0 +1,77 @@ +// 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.ui.disk.tree; + +import static cn.classfun.droidvm.lib.utils.StringUtils.basename; +import static cn.classfun.droidvm.lib.utils.StringUtils.bulletList; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.List; + +import cn.classfun.droidvm.R; + +/** Renders the announced part of a {@link CursorPlan} into confirmation text. */ +public final class CursorPlanText { + private CursorPlanText() { + } + + /** One line per change; slots are shown 1-based, as the editor numbers them. */ + @NonNull + public static String line(@NonNull Context ctx, @NonNull CursorPlan.Change c) { + var vm = c.from.vmName; + int slot = c.from.slot + 1; + var from = c.from.path == null ? "" : basename(c.from.path); + if (c.cleared()) + return ctx.getString(R.string.disk_tree_change_clear, vm, slot, from); + var to = c.to.path == null ? "" : basename(c.to.path); + if (!c.moved()) + return ctx.getString(R.string.disk_tree_change_readonly, vm, slot, from); + return ctx.getString(c.readonlyForced() + ? R.string.disk_tree_change_move_readonly : R.string.disk_tree_change_move, + vm, slot, from, to); + } + + /** A cursor whose disk keeps its path but has its content replaced (merge into base). */ + @NonNull + public static String rewrittenLine( + @NonNull Context ctx, @NonNull AttachmentCursor c, @NonNull String byName) { + return ctx.getString(R.string.disk_tree_change_rewritten, + c.vmName, c.slot + 1, c.path == null ? "" : basename(c.path), byName); + } + + /** + * The "other VMs' attachments change with it" paragraph, or an empty string when nothing + * needs announcing. {@code extraLines} are appended to the same list. + */ + @NonNull + public static String describe( + @NonNull Context ctx, + @NonNull List announced, + @NonNull List extraLines + ) { + var lines = new ArrayList(); + for (var c : announced) lines.add(line(ctx, c)); + lines.addAll(extraLines); + if (lines.isEmpty()) return ""; + return ctx.getString(R.string.disk_tree_changes_header, bulletList(lines)); + } + + @NonNull + public static String describe( + @NonNull Context ctx, @NonNull List announced) { + return describe(ctx, announced, List.of()); + } + + /** Refusal text naming the VMs whose pinned cursors the operation would have changed. */ + @NonNull + public static String pinnedMessage( + @NonNull Context ctx, @NonNull List refused) { + return ctx.getString(R.string.disk_tree_pinned, + bulletList(AttachmentCursors.vmNames(refused))); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskBranchPanel.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskBranchPanel.java new file mode 100644 index 00000000..53a2ef60 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskBranchPanel.java @@ -0,0 +1,383 @@ +// 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.ui.disk.tree; + +import static android.widget.Toast.LENGTH_SHORT; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.Gravity; +import android.view.View; +import android.view.ViewTreeObserver; +import android.widget.FrameLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.store.vm.VMStore; +import cn.classfun.droidvm.lib.ui.MaterialMenu; +import cn.classfun.droidvm.ui.disk.action.BackingChainLinker; +import cn.classfun.droidvm.ui.disk.action.DiskActionDialog; +import cn.classfun.droidvm.ui.disk.action.DiskOverlayCreateDialog; +import cn.classfun.droidvm.ui.disk.tree.AttachmentCursors.LiveRows; +import cn.classfun.droidvm.ui.vm.VmRunningQuery; + +/** + * The branch-management panel: the whole overlay family of a disk, every attachment on it, and + * a per-node menu that creates/deletes/merges/flattens branches while the panel STAYS OPEN, so + * one snapshot can be followed by the next without re-entering it. + * + *

    Opened from the VM disk editor it also carries that editor's unsaved rows as in-memory + * {@link AttachmentCursor}s. Those follow every operation by the rules in {@link CursorPlan} - + * the row that opened the panel is the active cursor - and are handed back in the + * {@link Result} when the panel closes: OK re-points the active row at whatever is selected, + * Back/Close at wherever its cursor drifted to (a cursor with nothing left under it removes the + * row). Other VMs' saved slots are rewritten by the operations themselves, after being announced + * in their confirmations; that part happens whether or not the editor is later saved. + * + *

    Merge and flatten run in another activity; the panel waits underneath and refreshes when + * its window regains focus. After every refresh the view re-roots on the active cursor's node, + * so a flattened branch shows as its own family from then on. + */ +public final class DiskBranchPanel { + private static final String TAG = "DiskBranchPanel"; + + public interface Listener { + /** The registry changed under the panel (main thread); hosts refresh their own lists. */ + default void onRegistryChanged() { + } + + /** The panel closed (main thread). */ + void onClosed(@NonNull Result result); + } + + public static final class Result { + /** OK pressed (editor mode only); Back/Close/outside-tap otherwise. */ + public final boolean confirmed; + /** + * Editor row index to its new path, only for rows that must change. A null value means + * the row's whole tree is gone and the row is to be removed. + */ + @NonNull + public final Map rows; + /** The disk the panel was opened for no longer exists. */ + public final boolean subjectGone; + + Result(boolean confirmed, @NonNull Map rows, boolean subjectGone) { + this.confirmed = confirmed; + this.rows = rows; + this.subjectGone = subjectGone; + } + } + + private final Context context; + private final UUID subjectId; + @Nullable + private final LiveRows live; + private final Listener listener; + private final Handler main = new Handler(Looper.getMainLooper()); + + // All written on the main thread; refresh() reads them from the pool only after being + // posted from the main thread, so it always sees the latest values. + private TreeShape shape = TreeShape.empty(); + private List liveCursors = new ArrayList<>(); + private boolean first = true; + + /** Where the active cursor sat at the last render; a radio left on it follows its moves. */ + @Nullable + private UUID lastActiveNode; + + private DiskTreeView tree; + private TextView emptyView; + private AlertDialog dialog; + private ViewTreeObserver.OnWindowFocusChangeListener focusListener; + private boolean confirmed; + private boolean refreshOnFocus; + private boolean closed; + + private DiskBranchPanel( + @NonNull Context context, + @NonNull UUID subjectId, + @Nullable LiveRows live, + @NonNull Listener listener + ) { + this.context = context; + this.subjectId = subjectId; + this.live = live; + this.listener = listener; + } + + /** + * Branch management for the disk at {@code currentPath}. Always available, even for a disk + * with no relatives yet - creating the first overlay is one of the actions here. + * + * @param live the disk editor's rows when opened from one (selection enabled, OK/Back + * buttons); null from the disk info screen (Close button only) + */ + public static void open( + @NonNull Context context, + @NonNull String currentPath, + @Nullable LiveRows live, + @NonNull Listener listener + ) { + var main = new Handler(Looper.getMainLooper()); + runOnPool(() -> { + var store = new DiskStore(); + store.load(context); + var current = store.findByPath(currentPath); + if (current == null) { + main.post(() -> Toast.makeText( + context, R.string.disk_tree_not_registered, LENGTH_SHORT).show()); + return; + } + // Reconcile links from the images' headers before drawing: a disk whose parent was + // never linked (registry predating the tree, or an outside rebase) should show its + // real family the first time this opens. + BackingChainLinker.repair(context, current.getId(), () -> + new DiskBranchPanel(context, current.getId(), live, listener).refresh()); + }); + } + + /** Reload both stores, move the in-memory cursors along, re-root and repaint. */ + private void refresh() { + runOnPool(() -> { + try { + var disks = new DiskStore(); + disks.load(context); + var vms = new VMStore(); + vms.load(vms, context); + var newShape = TreeShape.of(disks); + var all = newShape.nodes(); + var inUse = VmRunningQuery.inUseAmong( + AttachmentCursors.allVmNames(vms, live == null ? null : live.vmName)); + var persisted = AttachmentCursors.collectPersisted( + disks, vms, all, live == null ? null : live.vmId, inUse); + List liveNow; + if (first) { + liveNow = live == null ? List.of() + : AttachmentCursors.collectLive(disks, all, live, inUse); + } else { + var plan = CursorPlan.reconcile(liveCursors, persisted, shape, newShape); + boolean pinned = live != null && live.vmId != null + && inUse.contains(live.vmName); + liveNow = new ArrayList<>(); + for (var c : plan.cursors) liveNow.add(c.withPinned(pinned)); + } + UUID focus = null; + for (var c : liveNow) + if (c.kind == AttachmentCursor.Kind.ACTIVE && c.nodeId != null) + focus = c.nodeId; + if (focus == null && newShape.contains(subjectId)) focus = subjectId; + var family = focus == null ? null : DiskTree.buildFamily(disks, focus); + var familyIds = focus == null ? Set.of() : newShape.familyOf(focus); + var labels = labels(familyIds, liveNow, persisted); + final var activeNode = focus == null ? null : activeNodeOf(liveNow); + final var finalLive = liveNow; + main.post(() -> { + if (closed) return; + shape = newShape; + liveCursors = finalLive; + boolean wasFirst = first; + first = false; + if (dialog == null) build(); + render(family, familyIds, labels, activeNode); + if (!wasFirst) listener.onRegistryChanged(); + }); + } catch (Exception e) { + Log.w(TAG, "branch panel refresh failed", e); + } + }); + } + + @Nullable + private static UUID activeNodeOf(@NonNull List cursors) { + for (var c : cursors) + if (c.kind == AttachmentCursor.Kind.ACTIVE) return c.nodeId; + return null; + } + + /** "Attached: vm (#1), other (#2, in use)" per node; the active row is marked separately. */ + @NonNull + private Map labels( + @NonNull Set family, + @NonNull List liveNow, + @NonNull List persisted + ) { + var per = new HashMap>(); + var all = new ArrayList(liveNow); + all.addAll(persisted); + for (var c : all) { + if (c.nodeId == null || !family.contains(c.nodeId)) continue; + if (c.kind == AttachmentCursor.Kind.ACTIVE || c.kind == AttachmentCursor.Kind.SHADOW) + continue; + var who = c.vmName.isEmpty() ? fmt("#%d", c.slot + 1) + : fmt("%s (#%d)", c.vmName, c.slot + 1); + if (c.pinned) who = context.getString(R.string.disk_tree_in_use, who); + per.computeIfAbsent(c.nodeId, k -> new ArrayList<>()).add(who); + } + var out = new HashMap(); + for (var e : per.entrySet()) + out.put(e.getKey(), context.getString( + R.string.disk_tree_attached_by, String.join(", ", e.getValue()))); + return out; + } + + private void build() { + boolean selectable = live != null; + tree = new DiskTreeView(context); + tree.configure(selectable, true, null, new DiskTreeView.Listener() { + @Override + public void onNodeMenu(@NonNull View anchor, @NonNull DiskTree.Node node) { + showNodeMenu(anchor, node); + } + }); + emptyView = new TextView(context); + emptyView.setText(R.string.disk_tree_empty); + emptyView.setGravity(Gravity.CENTER); + int pad = Math.round(24 * context.getResources().getDisplayMetrics().density); + emptyView.setPadding(pad, pad, pad, pad); + emptyView.setVisibility(View.GONE); + var container = new FrameLayout(context); + container.addView(tree); + container.addView(emptyView); + var builder = new MaterialAlertDialogBuilder(context) + .setTitle(R.string.disk_manage_branches) + .setView(container); + if (selectable) { + builder.setPositiveButton(android.R.string.ok, (d, w) -> confirmed = true) + .setNegativeButton(R.string.disk_manage_branches_back, null); + } else { + builder.setPositiveButton(R.string.disk_manage_branches_close, null); + } + dialog = builder.create(); + dialog.setOnDismissListener(d -> onDismissed()); + dialog.show(); + var window = dialog.getWindow(); + if (window != null) { + focusListener = hasFocus -> { + if (hasFocus && refreshOnFocus) { + refreshOnFocus = false; + refresh(); + } + }; + window.getDecorView().getViewTreeObserver() + .addOnWindowFocusChangeListener(focusListener); + } + } + + private void render( + @Nullable DiskTree.Node family, + @NonNull Set familyIds, + @NonNull Map labels, + @Nullable UUID activeNode + ) { + if (family == null) { + tree.setVisibility(View.GONE); + emptyView.setVisibility(View.VISIBLE); + tree.updateRoots(List.of()); + lastActiveNode = null; + return; + } + tree.setVisibility(View.VISIBLE); + emptyView.setVisibility(View.GONE); + tree.updateRoots(List.of(family)); + tree.setCursorLabels(labels); + tree.setCurrentId(activeNode); + if (live != null) { + // The radio tracks the active cursor as long as the user left it there (so OK after + // a snapshot lands on the new overlay, same as Back would); a radio moved elsewhere + // stays put unless that node is gone. + var selected = tree.getSelectedId(); + if (selected == null || !familyIds.contains(selected) + || Objects.equals(selected, lastActiveNode)) + tree.setSelectedId(activeNode != null ? activeNode : family.id()); + } + lastActiveNode = activeNode; + } + + /** + * Per-node actions. None of them closes the panel: create and delete report back when the + * registry is written, merge and flatten hand over to another activity and the panel + * refreshes on return. Each states up front, in its own confirmation, where other VMs' + * attachments go; the editor's rows follow silently and are applied when the panel closes. + */ + private void showNodeMenu(@NonNull View anchor, @NonNull DiskTree.Node node) { + var popup = new MaterialMenu(context, anchor); + popup.inflate(R.menu.menu_disk_tree_node); + // Reset only makes sense for a writable leaf overlay: a base's content belongs to its + // overlays, and a root has nothing to reset to. Hide it rather than refuse it. + popup.setItemVisible(R.id.menu_disk_reset, + node.config.getParentId() != null && !node.hasChildren()); + popup.setOnMenuItemClickListener(item -> { + var actions = new DiskActionDialog(context, null, null); + int id = item.getItemId(); + if (id == R.id.menu_disk_create_increment) { + new DiskOverlayCreateDialog(context, node.config, this::refresh, null) + .setLiveRows(live) + .show(); + return true; + } else if (id == R.id.menu_disk_merge) { + actions.tryMerge(node.config, live, () -> refreshOnFocus = true); + return true; + } else if (id == R.id.menu_disk_flatten) { + actions.tryFlatten(node.config, () -> refreshOnFocus = true); + return true; + } else if (id == R.id.menu_disk_reset) { + actions.tryReset(node.config, live, this::refresh); + return true; + } else if (id == R.id.menu_disk_delete) { + actions.confirmDelete(node.config, live, this::refresh); + return true; + } + return false; + }); + popup.show(); + } + + private void onDismissed() { + if (closed) return; + closed = true; + var window = dialog.getWindow(); + if (window != null && focusListener != null) { + var observer = window.getDecorView().getViewTreeObserver(); + if (observer.isAlive()) observer.removeOnWindowFocusChangeListener(focusListener); + } + var rows = new LinkedHashMap(); + if (live != null) { + var selected = confirmed ? tree.getSelectedId() : null; + for (var c : liveCursors) { + String path; + if (c.kind == AttachmentCursor.Kind.ACTIVE + && selected != null && shape.contains(selected)) { + path = shape.pathOf(selected); + } else { + path = c.path; + } + var original = c.slot < live.paths.size() ? live.paths.get(c.slot) : null; + if (path == null || !path.equals(original)) rows.put(c.slot, path); + } + } + listener.onClosed(new Result(confirmed, rows, !shape.contains(subjectId))); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskTree.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskTree.java new file mode 100644 index 00000000..d1c24161 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskTree.java @@ -0,0 +1,165 @@ +// 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.ui.disk.tree; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import cn.classfun.droidvm.lib.store.disk.DiskConfig; +import cn.classfun.droidvm.lib.store.disk.DiskStore; + +/** + * The overlay-relation forest, built fresh from a {@link DiskStore} snapshot (the registry's + * {@code parent} links; the qcow2 headers remain the ground truth those links mirror). Pure + * logic - the tree views and dialogs all render flattened output of this class. + * + * Malformed registries degrade instead of failing: a parent id that isn't registered marks the + * node {@code brokenParent} and promotes it to a root; cycle members are promoted the same way + * with the cycle edge cut. Depth is capped only visually by callers - {@link #MAX_DEPTH} matches + * crosvm's nesting limit and is what creation flows should enforce. + */ +public final class DiskTree { + /** crosvm's MAX_NESTING_DEPTH; creating an overlay deeper than this can't boot anyway. */ + public static final int MAX_DEPTH = 10; + + public static final class Node { + @NonNull + public final DiskConfig config; + public final int depth; + /** Parent link exists but the parent isn't registered, or a cycle was cut here. */ + public final boolean brokenParent; + @NonNull + public final List children = new ArrayList<>(); + + Node(@NonNull DiskConfig config, int depth, boolean brokenParent) { + this.config = config; + this.depth = depth; + this.brokenParent = brokenParent; + } + + @NonNull + public UUID id() { + return config.getId(); + } + + public boolean hasChildren() { + return !children.isEmpty(); + } + + /** Number of descendants (the "+N" a collapsed row shows). */ + public int countDescendants() { + int n = 0; + for (var c : children) n += 1 + c.countDescendants(); + return n; + } + } + + private DiskTree() { + } + + /** Every family in the registry: roots in registry order, children nested below them. */ + @NonNull + public static List buildForest(@NonNull DiskStore store) { + var kids = new HashMap>(); + var rootCfgs = new ArrayList(); + var broken = new HashSet(); + for (int i = 0; i < store.size(); i++) { + var cfg = store.get(i); + var parentId = cfg.getParentId(); + if (parentId == null) { + rootCfgs.add(cfg); + } else if (store.findById(parentId) != null) { + kids.computeIfAbsent(parentId, k -> new ArrayList<>()).add(cfg); + } else { + broken.add(cfg.getId()); + rootCfgs.add(cfg); + } + } + var visited = new HashSet(); + var forest = new ArrayList(); + for (var cfg : rootCfgs) + forest.add(buildNode(cfg, 0, kids, visited, broken, false)); + // Cycle members are reachable from no root; promote each still-unvisited config (in + // registry order, so the promotion is deterministic) - the visited guard cuts the loop. + // Only the promoted node is marked broken; its descendants' links are intact. + for (int i = 0; i < store.size(); i++) { + var cfg = store.get(i); + if (!visited.contains(cfg.getId())) + forest.add(buildNode(cfg, 0, kids, visited, broken, true)); + } + return forest; + } + + /** The whole family tree containing {@code id} (walks up to the root, then expands). */ + @Nullable + public static Node buildFamily(@NonNull DiskStore store, @NonNull UUID id) { + var rootId = rootOf(store, id); + for (var root : buildForest(store)) + if (root.id().equals(rootId)) return root; + return null; + } + + /** The root of {@code id}'s family; {@code id} itself on a broken link or cycle. */ + @NonNull + public static UUID rootOf(@NonNull DiskStore store, @NonNull UUID id) { + var visited = new HashSet(); + var current = id; + while (visited.add(current)) { + var cfg = store.findById(current); + if (cfg == null) break; + var parentId = cfg.getParentId(); + if (parentId == null || store.findById(parentId) == null) return current; + current = parentId; + } + return id; // cycle - treat the queried node as its own root + } + + /** + * Depth-first flatten for list display. A node in {@code collapsedIds} is emitted but its + * descendants are skipped. + */ + @NonNull + public static List flatten( + @NonNull List roots, @NonNull Set collapsedIds) { + var out = new ArrayList(); + for (var root : roots) flattenInto(root, collapsedIds, out); + return out; + } + + private static void flattenInto( + @NonNull Node node, @NonNull Set collapsedIds, @NonNull List out) { + out.add(node); + if (collapsedIds.contains(node.id())) return; + for (var child : node.children) flattenInto(child, collapsedIds, out); + } + + @NonNull + private static Node buildNode( + @NonNull DiskConfig cfg, + int depth, + @NonNull Map> kids, + @NonNull Set visited, + @NonNull Set broken, + boolean cycleCut + ) { + visited.add(cfg.getId()); + var node = new Node(cfg, depth, cycleCut || broken.contains(cfg.getId())); + var children = kids.get(cfg.getId()); + if (children != null) { + for (var child : children) { + if (visited.contains(child.getId())) continue; // cycle edge - cut + node.children.add(buildNode(child, depth + 1, kids, visited, broken, false)); + } + } + return node; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskTreeCollapse.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskTreeCollapse.java new file mode 100644 index 00000000..fdad323e --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskTreeCollapse.java @@ -0,0 +1,49 @@ +// 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.ui.disk.tree; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +/** + * Which tree nodes the user collapsed on the main disk list, persisted so the list keeps its + * shape across sessions. Default is expanded - the point of the tree is seeing what stacks on + * what - so only explicitly collapsed ids are stored. Dialog trees keep their own in-memory + * state and don't touch this. + */ +public final class DiskTreeCollapse { + private static final String PREFS_NAME = "droidvm_prefs"; + private static final String KEY = "disk_tree_collapsed"; + + private DiskTreeCollapse() { + } + + @NonNull + public static Set load(@NonNull Context context) { + var raw = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getStringSet(KEY, null); + var out = new HashSet(); + if (raw != null) { + for (var s : raw) { + try { + out.add(UUID.fromString(s)); + } catch (IllegalArgumentException ignored) { + } + } + } + return out; + } + + public static void save(@NonNull Context context, @NonNull Set collapsed) { + var raw = new HashSet(); + for (var id : collapsed) raw.add(id.toString()); + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit().putStringSet(KEY, raw).apply(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskTreeView.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskTreeView.java new file mode 100644 index 00000000..57bd14ef --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/DiskTreeView.java @@ -0,0 +1,254 @@ +// 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.ui.disk.tree; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.radiobutton.MaterialRadioButton; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.ui.disk.create.DiskFormat; + +/** + * Compact rendering of an overlay-relation tree, used inside dialogs (branch panel, disk + * picker) and info views. Feed it {@link DiskTree} roots; rows indent by depth, parents get a + * working collapse chevron (in-memory state - the main list's persisted collapse is separate), + * locked disks show the padlock, broken links a warning line, and an optional per-node label + * (which VMs attach it). Optional single-selection with a radio column and an optional per-node + * menu button. {@link #updateRoots} swaps the tree in place, keeping collapse and selection for + * nodes that survived. + */ +public final class DiskTreeView extends RecyclerView { + public interface Listener { + /** Row tapped in selectable mode (already reflected in the UI). */ + default void onNodeSelected(@NonNull DiskTree.Node node) { + } + + /** Node menu button tapped. */ + default void onNodeMenu(@NonNull View anchor, @NonNull DiskTree.Node node) { + } + } + + private static final int INDENT_DP = 16; + private static final int MAX_INDENT_STEPS = 4; + + private final Adapter adapter = new Adapter(); + private List roots = new ArrayList<>(); + private final List flat = new ArrayList<>(); + private final Set collapsed = new HashSet<>(); + private final Map labels = new HashMap<>(); + private boolean selectable = false; + private boolean showNodeMenu = false; + @Nullable + private UUID currentId; + @Nullable + private UUID selectedId; + @Nullable + private Listener listener; + + public DiskTreeView(@NonNull Context context) { + super(context); + init(); + } + + public DiskTreeView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(); + } + + public DiskTreeView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + init(); + } + + private void init() { + setLayoutManager(new LinearLayoutManager(getContext())); + setAdapter(adapter); + } + + public void configure( + boolean selectable, + boolean showNodeMenu, + @Nullable UUID currentId, + @Nullable Listener listener + ) { + this.selectable = selectable; + this.showNodeMenu = showNodeMenu; + this.currentId = currentId; + this.listener = listener; + if (selectable) this.selectedId = currentId; + } + + /** Replace the tree contents (fully expanded) and repaint. */ + public void setRoots(@NonNull List roots) { + this.roots = roots; + collapsed.clear(); + rebuild(); + } + + /** Replace the tree contents, keeping collapse state of nodes still present. */ + public void updateRoots(@NonNull List roots) { + this.roots = roots; + var present = new HashSet(); + for (var n : DiskTree.flatten(roots, Set.of())) present.add(n.id()); + collapsed.retainAll(present); + if (selectedId != null && !present.contains(selectedId)) selectedId = null; + rebuild(); + } + + /** The node marked "attached" (the caller's own attachment). */ + public void setCurrentId(@Nullable UUID currentId) { + this.currentId = currentId; + adapter.notifyDataSetChanged(); + } + + public void setSelectedId(@Nullable UUID id) { + this.selectedId = id; + adapter.notifyDataSetChanged(); + } + + /** Per-node text shown under the name (e.g. which VMs attach it). */ + public void setCursorLabels(@NonNull Map labels) { + this.labels.clear(); + this.labels.putAll(labels); + adapter.notifyDataSetChanged(); + } + + @Nullable + public UUID getSelectedId() { + return selectedId; + } + + @Nullable + public DiskTree.Node getSelectedNode() { + if (selectedId == null) return null; + for (var n : DiskTree.flatten(roots, Set.of())) + if (n.id().equals(selectedId)) return n; + return null; + } + + @SuppressLint("NotifyDataSetChanged") + private void rebuild() { + flat.clear(); + flat.addAll(DiskTree.flatten(roots, collapsed)); + adapter.notifyDataSetChanged(); + } + + private final class Adapter extends RecyclerView.Adapter { + @NonNull + @Override + public Holder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + var v = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.item_disk_tree_row, parent, false); + return new Holder(v); + } + + @Override + public void onBindViewHolder(@NonNull Holder h, int position) { + h.bind(flat.get(position)); + } + + @Override + public int getItemCount() { + return flat.size(); + } + } + + private final class Holder extends RecyclerView.ViewHolder { + private final View root; + private final ImageButton chevron; + private final ImageView icon; + private final TextView name; + private final TextView sub; + private final ImageView lock; + private final MaterialRadioButton radio; + private final ImageButton menu; + + Holder(@NonNull View v) { + super(v); + root = v.findViewById(R.id.tree_row_root); + chevron = v.findViewById(R.id.tree_chevron); + icon = v.findViewById(R.id.tree_icon); + name = v.findViewById(R.id.tree_name); + sub = v.findViewById(R.id.tree_sub); + lock = v.findViewById(R.id.tree_lock); + radio = v.findViewById(R.id.tree_radio); + menu = v.findViewById(R.id.tree_menu); + } + + @SuppressLint("NotifyDataSetChanged") + void bind(@NonNull DiskTree.Node node) { + var ctx = root.getContext(); + float density = ctx.getResources().getDisplayMetrics().density; + int steps = Math.min(node.depth, MAX_INDENT_STEPS); + root.setPaddingRelative( + Math.round(steps * INDENT_DP * density), + root.getPaddingTop(), root.getPaddingEnd(), root.getPaddingBottom()); + + name.setText(node.config.getName()); + icon.setImageResource(node.config.getFormat() == DiskFormat.ISO + ? R.drawable.ic_cdrom : R.drawable.ic_nav_disk); + + boolean isCollapsed = collapsed.contains(node.id()); + chevron.setVisibility(node.hasChildren() ? VISIBLE : View.INVISIBLE); + chevron.setRotation(isCollapsed ? -90 : 0); + chevron.setOnClickListener(v -> { + if (!collapsed.remove(node.id())) collapsed.add(node.id()); + rebuild(); + }); + + lock.setVisibility(node.hasChildren() ? VISIBLE : GONE); + + var parts = new ArrayList(); + if (node.brokenParent) parts.add(ctx.getString(R.string.disk_tree_broken_parent)); + if (isCollapsed && node.hasChildren()) parts.add(fmt("+%d", node.countDescendants())); + if (currentId != null && currentId.equals(node.id())) + parts.add(ctx.getString(R.string.disk_tree_current_attached)); + var label = labels.get(node.id()); + if (label != null && !label.isEmpty()) parts.add(label); + sub.setVisibility(parts.isEmpty() ? GONE : VISIBLE); + sub.setText(String.join(" ", parts)); + + radio.setVisibility(selectable ? VISIBLE : GONE); + radio.setChecked(selectable && node.id().equals(selectedId)); + + menu.setVisibility(showNodeMenu ? VISIBLE : GONE); + menu.setOnClickListener(v -> { + if (listener != null) listener.onNodeMenu(v, node); + }); + + root.setOnClickListener(v -> { + if (!selectable) return; + selectedId = node.id(); + adapter.notifyDataSetChanged(); + if (listener != null) listener.onNodeSelected(node); + }); + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/TreeShape.java b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/TreeShape.java new file mode 100644 index 00000000..6f0c47d3 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/disk/tree/TreeShape.java @@ -0,0 +1,192 @@ +// 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.ui.disk.tree; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import cn.classfun.droidvm.lib.store.disk.DiskStore; + +/** + * The registry's parent links at one instant, plus each node's path and display name. Pure and + * immutable; the {@code with*} builders return the shape an operation WOULD leave behind, so a + * {@link CursorPlan} can be computed (and shown to the user) before anything runs, with the very + * same code that reconciles attachments after it ran. + */ +public final class TreeShape { + private final Set nodes = new LinkedHashSet<>(); + private final Map parentOf = new HashMap<>(); + private final Map pathOf = new HashMap<>(); + private final Map nameOf = new HashMap<>(); + + private TreeShape() { + } + + /** Snapshot of a whole registry; a parent id that isn't registered reads as "root". */ + @NonNull + public static TreeShape of(@NonNull DiskStore store) { + var shape = new TreeShape(); + for (int i = 0; i < store.size(); i++) { + var cfg = store.get(i); + shape.put(cfg.getId(), cfg.getFullPath(), cfg.getName(), null); + } + for (int i = 0; i < store.size(); i++) { + var cfg = store.get(i); + var parent = cfg.getParentId(); + if (parent != null && shape.nodes.contains(parent)) + shape.parentOf.put(cfg.getId(), parent); + } + return shape; + } + + /** Empty shape for building by hand (tests, predictions). */ + @NonNull + public static TreeShape empty() { + return new TreeShape(); + } + + /** Add or replace a node; a null parent makes it a root. Returns this for chaining. */ + @NonNull + public TreeShape put( + @NonNull UUID id, @NonNull String path, @NonNull String name, @Nullable UUID parent) { + nodes.add(id); + pathOf.put(id, path); + nameOf.put(id, name); + if (parent == null) parentOf.remove(id); + else parentOf.put(id, parent); + return this; + } + + public boolean contains(@NonNull UUID id) { + return nodes.contains(id); + } + + @NonNull + public Set nodes() { + return new LinkedHashSet<>(nodes); + } + + @Nullable + public UUID parentOf(@NonNull UUID id) { + return parentOf.get(id); + } + + @Nullable + public String pathOf(@NonNull UUID id) { + return pathOf.get(id); + } + + @Nullable + public String nameOf(@NonNull UUID id) { + return nameOf.get(id); + } + + public boolean hasChildren(@NonNull UUID id) { + return parentOf.containsValue(id); + } + + /** {@code id} and every descendant, cycle-guarded; empty when {@code id} is unknown. */ + @NonNull + public Set subtreeOf(@NonNull UUID id) { + var out = new LinkedHashSet(); + if (!nodes.contains(id)) return out; + var queue = new ArrayDeque(); + queue.add(id); + while (!queue.isEmpty()) { + var cur = queue.poll(); + if (!out.add(cur)) continue; + for (var e : parentOf.entrySet()) + if (cur.equals(e.getValue())) queue.add(e.getKey()); + } + return out; + } + + /** Root of {@code id}'s family (itself on a cycle). */ + @NonNull + public UUID rootOf(@NonNull UUID id) { + var seen = new HashSet(); + var cur = id; + while (seen.add(cur)) { + var p = parentOf.get(cur); + if (p == null) return cur; + cur = p; + } + return id; + } + + /** All nodes sharing a root with {@code id}. */ + @NonNull + public Set familyOf(@NonNull UUID id) { + return subtreeOf(rootOf(id)); + } + + // ---- predictions ------------------------------------------------------------------------- + + @NonNull + private TreeShape copy() { + var s = new TreeShape(); + s.nodes.addAll(nodes); + s.parentOf.putAll(parentOf); + s.pathOf.putAll(pathOf); + s.nameOf.putAll(nameOf); + return s; + } + + /** After deleting {@code removed} (callers pass a whole subtree). */ + @NonNull + public TreeShape without(@NonNull Set removed) { + var s = copy(); + for (var id : removed) { + s.nodes.remove(id); + s.parentOf.remove(id); + s.pathOf.remove(id); + s.nameOf.remove(id); + } + s.parentOf.values().removeIf(removed::contains); + return s; + } + + /** After merging {@code node} into its parent: node gone, its children re-based onto it. */ + @NonNull + public TreeShape withMerged(@NonNull UUID node) { + var s = copy(); + var parent = parentOf.get(node); + var children = new ArrayList(); + for (var e : s.parentOf.entrySet()) + if (node.equals(e.getValue())) children.add(e.getKey()); + for (var child : children) { + if (parent == null) s.parentOf.remove(child); + else s.parentOf.put(child, parent); + } + s.nodes.remove(node); + s.parentOf.remove(node); + s.pathOf.remove(node); + s.nameOf.remove(node); + return s; + } + + /** After creating overlay {@code newId} on top of {@code under}. */ + @NonNull + public TreeShape withChild( + @NonNull UUID newId, @NonNull String path, @NonNull String name, @NonNull UUID under) { + return copy().put(newId, path, name, under); + } + + /** After flattening {@code node}: it keeps its children but becomes a root of its own. */ + @NonNull + public TreeShape withDetached(@NonNull UUID node) { + var s = copy(); + s.parentOf.remove(node); + return s; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/AcquireContainerView.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/AcquireContainerView.java index 1a56bf0a..ea0d5fa1 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/AcquireContainerView.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/AcquireContainerView.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.ui.hugepage; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java index 261e1c59..b98594c4 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageActivity.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.ui.hugepage; import static android.view.View.GONE; @@ -93,6 +96,7 @@ public final class HugePageActivity extends AppCompatActivity { // to fill (avail+served < want); disabled at target, unloaded, or soft-disabled. private boolean acquireEnabled = false; private MaterialButton btnViewProcesses; + private TextRowWidget rowAdvanced; private View btnAcquireV1; private View btnAcquireV2; private View btnAcquireV3; @@ -159,6 +163,7 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { rowStatTotalServed = findViewById(R.id.row_stat_total_served); rowStatTotalRefilled = findViewById(R.id.row_stat_total_refilled); rowStatActiveVms = findViewById(R.id.row_stat_active_vms); + rowAdvanced = findViewById(R.id.row_advanced); initialize(); } @@ -204,6 +209,10 @@ public void afterTextChanged(Editable s) { }); btnViewProcesses.setOnClickListener(v -> startActivity( new Intent(this, HugePageProcessActivity.class))); + // The loader-fed knobs live on their own screen: they are insmod-time + // policy, not something this dashboard should invite a tap on. + rowAdvanced.setOnClickListener(v -> startActivity( + new Intent(this, HugePageAdvancedActivity.class))); // Acquire-mode slots: each button starts its mode; each spinner (shown // while a run is in flight) interrupts it. Listeners are static -- the // slots aren't recycled -- and the idle/running visibility toggle is @@ -746,34 +755,46 @@ private void stopAcquireAndWait() { private void loadPoolSize() { runOnPool(() -> { - Map settings; - try { - var result = shellReadFile(SETTINGS_PROP); - settings = parseProp(result); - } catch (Exception e) { - Log.w(TAG, "Failed to read settings.prop", e); - return; - } - // Prefer pool_want; fall back to legacy pool_target. - var cur = settings.getOrDefault("pool_want", - settings.getOrDefault("pool_target", "1024")); - if (cur == null || cur.isEmpty()) cur = "1024"; - try { - var pages = Long.parseLong(cur); - var bytes = BigInteger.valueOf(pages * PAGE_SIZE); - runOnUiThread(() -> { - // Programmatic seed - don't count it as a user edit for - // the pool<->with-CMA size link. - sizeLinkSyncing = true; - try { - inputPoolSize.setBigValue(bytes); - } finally { - sizeLinkSyncing = false; - } - }); - } catch (NumberFormatException e) { - Log.w(TAG, "Failed to parse pool_want", e); + // Seed from the MODULE's live readback first: the kernel clamps an + // oversized write to pool_size_max, so settings.prop may say 10G + // while the target actually in force is 8.71G. The prop keeps the + // bigger wish (insmod re-clamps it every boot); the input must show + // the effective value. settings.prop is only the fallback while the + // module is not loaded (nothing to read back from). + long pages = -1; + var snap = model.state(); + if (snap.loaded && snap.statsOk) pages = snap.targetIdeal; + if (pages < 0) { + Map settings; + try { + var result = shellReadFile(SETTINGS_PROP); + settings = parseProp(result); + } catch (Exception e) { + Log.w(TAG, "Failed to read settings.prop", e); + return; + } + // Prefer pool_want; fall back to legacy pool_target. + var cur = settings.getOrDefault("pool_want", + settings.getOrDefault("pool_target", "1024")); + if (cur == null || cur.isEmpty()) cur = "1024"; + try { + pages = Long.parseLong(cur.trim()); + } catch (NumberFormatException e) { + Log.w(TAG, "Failed to parse pool_want", e); + return; + } } + var bytes = BigInteger.valueOf(pages * PAGE_SIZE); + runOnUiThread(() -> { + // Programmatic seed - don't count it as a user edit for + // the pool<->with-CMA size link. + sizeLinkSyncing = true; + try { + inputPoolSize.setBigValue(bytes); + } finally { + sizeLinkSyncing = false; + } + }); }); } @@ -882,7 +903,15 @@ private void savePoolSize() { : appliedNow ? R.string.hugepage_pool_size_applied : R.string.hugepage_pool_size_saved; Toast.makeText(this, msg, LENGTH_SHORT).show(); - refreshStatus(); + // Re-seed BOTH inputs from the module's readback, because the + // pair is coupled (sec. 4): the kernel clamps each to pool_size_max + // AND links them - writing pool_want up drags pool_want_with_cma + // up with it; a with-CMA total below the pool is lifted back to + // it; both round up to a multiple of S. So one save can move the + // OTHER field too - show what actually stuck on each. + cmaInputLoaded = false; // updateUI reseeds the CMA total from + refreshStatus(); // snap.wantWithCma on its next pass + loadPoolSize(); // reseed the pool input (clamped want) }); }); } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageAdvancedActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageAdvancedActivity.java new file mode 100644 index 00000000..4ac03a08 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageAdvancedActivity.java @@ -0,0 +1,258 @@ +// 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.ui.hugepage; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static android.widget.Toast.LENGTH_SHORT; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; + +import android.os.Bundle; +import android.os.SystemClock; +import android.text.Editable; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; + +import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputLayout; + +import java.util.LinkedHashMap; +import java.util.Map; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.ui.SimpleTextWatcher; +import cn.classfun.droidvm.ui.widgets.row.TextRowWidget; + +/** + * The advanced knobs: the settings.prop keys {@code load.sh} feeds the module at + * insmod, which the main screen has no business guessing for the user. + * + *

    Everything here is deliberately raw. Each row is labelled with the + * parameter's own name, shows the settings.prop value, and carries the module's + * current readback underneath. Nothing is renamed into friendlier language: the + * only people who should be on this screen are reading the module's own + * documentation, and a second vocabulary would just stand between them and it. + * + *

    Showing both numbers is the point. They disagree while a saved value waits + * for the next load, and also when the insmod ladder degraded past the rung that + * carries that parameter - a single number would hide both cases. + * + *

    All reads and writes go through {@link HugePageModel}; this screen never + * touches sysfs itself. + */ +public final class HugePageAdvancedActivity extends AppCompatActivity { + /** + * The one key with no edit affordance. Lowering the reserve is how a phone is + * made unstable, so its row stays a label and the editor sits behind a + * deliberate gesture - anyone who reaches it went looking for it. + */ + private static final String KEY_SYSTEM_RESERVE = "system_reserve_mb"; + private static final int RESERVE_TAPS = 10; + /** Taps further apart than this start the count over ("consecutive"). */ + private static final long RESERVE_TAP_WINDOW_MS = 1500; + + private final HugePageModel model = new HugePageModel(); + /** Rows keyed by parameter name, in {@link HugePageModel#ADVANCED_KEYS} order. */ + private final Map rows = new LinkedHashMap<>(); + /** Last read of the knobs, so opening an editor needs no shell I/O. */ + private Map knobs = Map.of(); + /** This device's default reserve (MB), or -1 when the module can't say. */ + private int reserveDefaultMb = -1; + private int reserveTapCount; + private long reserveLastTapMs; + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_hugepage_advanced); + MaterialToolbar toolbar = findViewById(R.id.toolbar); + toolbar.setTitle(R.string.hugepage_advanced); + toolbar.setNavigationOnClickListener(v -> finish()); + + rows.put(KEY_SYSTEM_RESERVE, findViewById(R.id.row_adv_system_reserve)); + rows.put("cma_reservoir_floor_mb", findViewById(R.id.row_adv_cma_floor)); + rows.put("boot_acquire", findViewById(R.id.row_adv_boot_acquire)); + rows.put("boot_acquire_runs", findViewById(R.id.row_adv_boot_acquire_runs)); + rows.put("boot_acquire_wait", findViewById(R.id.row_adv_boot_acquire_wait)); + for (var e : rows.entrySet()) { + var key = e.getKey(); + e.getValue().setText(key); // the raw name, never a translation + if (KEY_SYSTEM_RESERVE.equals(key)) + e.getValue().setOnClickListener(v -> onReserveTap()); + else + e.getValue().setOnClickListener(v -> showEditor(key)); + } + } + + @Override + protected void onResume() { + super.onResume(); + load(); + } + + /** + * Ten taps in a row open the {@code system_reserve_mb} editor, and nothing + * before the tenth acknowledges them. The silence is the design: a stray + * double-tap must not offer to shrink the reserve that keeps the phone alive. + */ + private void onReserveTap() { + var now = SystemClock.uptimeMillis(); + reserveTapCount = now - reserveLastTapMs > RESERVE_TAP_WINDOW_MS + ? 1 : reserveTapCount + 1; + reserveLastTapMs = now; + if (reserveTapCount < RESERVE_TAPS) return; + reserveTapCount = 0; + showEditor(KEY_SYSTEM_RESERVE); + } + + /** + * Re-read every knob. The list shows one number per row: what the module is + * actually running. What settings.prop asks for is a different fact - + * it may be waiting for the next load, or have been dropped by the insmod + * ladder - and it belongs where it can be explained and changed, which is + * the editor, not a column. + */ + private void load() { + runOnPool(() -> { + var read = model.advancedKnobs(); + var def = model.systemReserveDefaultMb(); + runOnUiThread(() -> { + if (isFinishing()) return; + knobs = read; + reserveDefaultMb = def; + for (var e : rows.entrySet()) { + var knob = read.get(e.getKey()); + e.getValue().setValue(knob == null || knob.live == null + ? getString(R.string.hugepage_adv_unavailable) : knob.live); + } + }); + }); + } + + /** + * Edit one key: save a value, or clear it back to the module default. The + * header repeats the module's own readback (plus, for the reserve, this + * device's default) so the number being changed sits next to the number in + * force. Values come from the last {@link #load()} - opening an editor must + * not do shell I/O on the UI thread. + */ + private void showEditor(@NonNull String key) { + var view = getLayoutInflater().inflate(R.layout.dialog_hugepage_advanced, null); + TextView liveView = view.findViewById(R.id.tv_adv_live); + TextInputLayout til = view.findViewById(R.id.til_adv_value); + EditText input = view.findViewById(R.id.et_adv_value); + TextView warning = view.findViewById(R.id.tv_adv_warning); + + var knob = knobs.get(key); + var unavailable = getString(R.string.hugepage_adv_unavailable); + var header = new StringBuilder(fmt("sysfs = %s", + knob == null || knob.live == null ? unavailable : knob.live)); + if (KEY_SYSTEM_RESERVE.equals(key)) + header.append(fmt("\nsystem_reserve_mb_default = %s", reserveDefaultMb < 0 + ? unavailable : Integer.toString(reserveDefaultMb))); + liveView.setText(header); + til.setHint(key); + // Empty field = no settings.prop key = the module's own default. The + // placeholder says so in the one place the state can be acted on. + til.setPlaceholderText(getString(R.string.hugepage_adv_unset)); + if (knob != null && knob.saved != null) { + input.setText(knob.saved); + input.setSelection(input.getText().length()); + } + + var dialog = new MaterialAlertDialogBuilder(this) + .setTitle(key) + .setView(view) + // Empty field = no key = the module default, so Save covers clearing + // too and there is no second button to explain the difference. + .setPositiveButton(R.string.hugepage_save_pool_size, (d, w) -> { + var raw = input.getText().toString().trim(); + save(key, raw.isEmpty() ? null : raw); + }) + .setNegativeButton(android.R.string.cancel, null) + .create(); + dialog.setOnShowListener(d -> { + var ok = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + Runnable validate = () -> { + var raw = input.getText().toString().trim(); + var value = parse(key, raw); + ok.setEnabled(raw.isEmpty() || value != null); // empty = clear + til.setError(value == null && !raw.isEmpty() + ? getString(R.string.hugepage_adv_invalid) : null); + // Only the reserve has a "too low". The module hands back THIS + // device's default, so the threshold is right even where RAM/2 + // already caps the built-in one and lowering to it is a no-op. + var low = value != null && KEY_SYSTEM_RESERVE.equals(key) + && reserveDefaultMb > 0 && value < reserveDefaultMb; + if (low) + warning.setText(getString( + R.string.hugepage_adv_warn_below_default, reserveDefaultMb)); + warning.setVisibility(low ? VISIBLE : GONE); + }; + input.addTextChangedListener(new SimpleTextWatcher() { + @Override + public void afterTextChanged(Editable e) { + validate.run(); + } + }); + validate.run(); + }); + dialog.show(); + } + + /** Parse and range-check one knob; null = don't let it be saved. */ + @Nullable + private static Long parse(@NonNull String key, @NonNull String raw) { + long value; + try { + value = Long.parseLong(raw.trim()); + } catch (NumberFormatException e) { + return null; + } + long min, max; + switch (key) { + case KEY_SYSTEM_RESERVE: + min = 64; // the module's own floor + max = 1024L * 1024; + break; + case "boot_acquire": + min = 0; + max = 3; // the acquire knob's modes + break; + case "boot_acquire_runs": + min = 1; + max = 99; + break; + case "boot_acquire_wait": + min = 0; + max = 3600; + break; + default: // cma_reservoir_floor_mb + min = 0; + max = 1024L * 1024; + } + return value < min || value > max ? null : value; + } + + /** Persist one key ({@code null} clears it) and re-read what stuck. */ + private void save(@NonNull String key, @Nullable String value) { + runOnPool(() -> { + var res = model.saveAdvanced(key, value); + runOnUiThread(() -> { + Toast.makeText(this, res.ok() + ? R.string.hugepage_adv_saved + : R.string.hugepage_adv_save_failed, LENGTH_SHORT).show(); + load(); + }); + }); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageColor.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageColor.java index 7287fbef..a19f34a9 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageColor.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageColor.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.ui.hugepage; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java index aef1fb61..ca3a6abd 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageModel.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.ui.hugepage; import static cn.classfun.droidvm.lib.utils.FileUtils.shellCheckExists; @@ -545,6 +548,99 @@ void clearLegacyProbeKey() { updateSettings(changes); } + /* ================================================================== */ + /* Advanced knobs: loader-fed settings.prop keys */ + /* ================================================================== */ + + /** + * The keys the advanced screen edits, in display order. All five are read by + * {@code load.sh} out of settings.prop and handed to the module at insmod; + * none of them is something the GUI can decide for the user, which is why + * they live behind an advanced section that shows raw key/value. + */ + static final List ADVANCED_KEYS = List.of( + "system_reserve_mb", "cma_reservoir_floor_mb", + "boot_acquire", "boot_acquire_runs", "boot_acquire_wait"); + + /** + * Knobs whose module param is writable at runtime (0600), so a save shows up + * without waiting for the next load. Two different reasons to be on this + * list: {@code cma_reservoir_floor_mb} is consulted on every flip, so writing + * it actually takes effect; the {@code boot_acquire} trio is writable + * precisely because the module never reads it - the write changes nothing and + * exists so a saved setting is visible somewhere the user can see it. + * + *

    {@code system_reserve_mb} is deliberately absent: it sizes a table built + * once at insmod, so a later write would only lie about what is in force. An + * older module has these as 0400 and the write simply fails - the value still + * persisted, it just waits for the next load. + */ + private static final List LIVE_WRITABLE = List.of( + "cma_reservoir_floor_mb", "boot_acquire", "boot_acquire_runs", "boot_acquire_wait"); + + /** Module param carrying THIS device's default reserve, {@code min(RAM/2, 6144)}. */ + private static final String SYSTEM_RESERVE_DEFAULT = "system_reserve_mb_default"; + + /** + * One advanced knob, as the two facts that can disagree: what settings.prop + * asks for and what the module is actually running. They differ while a save + * awaits the next load, and also when the insmod ladder degraded past the rung + * carrying that param - which is exactly the case a single number would hide. + */ + static final class Knob { + /** settings.prop value, or null when the key is absent (module default). */ + @Nullable final String saved; + /** Module readback, or null when unavailable (not loaded, or older .ko). */ + @Nullable final String live; + + private Knob(@Nullable String saved, @Nullable String live) { + this.saved = saved; + this.live = live; + } + } + + /** Read every advanced knob: one settings.prop parse plus one read per param. */ + @NonNull + Map advancedKnobs() { + var saved = parseProp(safeRead(SETTINGS_PROP)); + var out = new LinkedHashMap(); + for (var key : ADVANCED_KEYS) { + var live = safeRead(pathJoin(SYSFS_PARAMS, key)).trim(); + out.put(key, new Knob(saved.get(key), live.isEmpty() ? null : live)); + } + return out; + } + + /** + * This device's default reserve in MB, or -1 when the module can't say. It is + * NOT the built-in 6144: the module caps the request at half of RAM, so an + * 8 GB phone defaults to 4096 and anything above that is a no-op there. A + * configured value overwrites {@code system_reserve_mb}, so this param is the + * only place the default survives once the user has set one. + */ + int systemReserveDefaultMb() { + return readIntParam(SYSTEM_RESERVE_DEFAULT, -1); + } + + /** + * Persist one advanced key ({@code null} removes it, restoring the module + * default), and apply it live when the param allows it. Persisting is the + * operation that matters - these keys exist to survive a reload - so a failed + * live write is not a failure, just a value that waits for the next load. + */ + @NonNull + Result saveAdvanced(@NonNull String key, @Nullable String value) { + var changes = new LinkedHashMap(); + changes.put(key, value); + var t = updateSettings(changes); + if (!t.ok()) return Result.failed("settings", t.error); + if (value != null && LIVE_WRITABLE.contains(key) + && existsSticky(pathJoin(SYSFS_PARAMS, key)) + && writeKnob(key, value).ok()) + return Result.ok(key); + return Result.ok("settings"); + } + /** Read an integer sysfs param, or {@code def} when absent/unparseable. */ private int readIntParam(@NonNull String name, int def) { var v = safeRead(pathJoin(SYSFS_PARAMS, name)).trim(); @@ -597,9 +693,33 @@ Result saveCmaTarget(long pages) { */ @NonNull Result saveTargets(long pages, long withCma) { - // Invariant (module plan.md sec.1): pool_want <= pool_want_with_cma. The kernel - // clamps a low live write itself, but the persisted pair must agree - // too or the next boot would insmod inconsistent targets. + // Persist the EFFECTIVE targets, not the raw input. The module is the + // single source of truth for clamping (to pool_size_max), coupling + // (pool_want <= pool_want_with_cma, sec. 4) and S-alignment. So when it is + // loaded: write the user's values to sysfs, read the pair BACK, and + // persist what actually stuck. settings.prop, the running pool and the + // UI then all agree, and the coupling/clamp/align rules are never + // reimplemented here (they drift - this once did its own Math.max yet + // still missed size_max and S-alignment). + if (existsSticky(pathJoin(SYSFS_PARAMS, "pool_want"))) { + var live = writeKnob("pool_want", Long.toString(pages)); + boolean liveOk = live.ok(); + if (withCma >= 0) + liveOk &= writeKnob("pool_want_with_cma", Long.toString(withCma)).ok(); + long effWant = readKnobLong("pool_want", pages); + long effCma = withCma >= 0 ? readKnobLong("pool_want_with_cma", withCma) : -1; + var changes = new LinkedHashMap(); + changes.put("pool_want", Long.toString(effWant)); + changes.put("pool_target", Long.toString(effWant)); + if (effCma >= 0) changes.putAll(cmaTargetChanges(effCma)); + var persisted = updateSettings(changes); + if (!persisted.ok()) return Result.failed("settings", persisted.error); + return Result.ok(liveOk ? "pool_want" : "settings", !liveOk); + } + // Not loaded: no sysfs to clamp against. Persist the raw values (with the + // minimal want<=with_cma coupling so the prop is self-consistent); insmod + // clamps/aligns them next boot, and the UI reads the effective pair back + // once the module is up. if (withCma >= 0) withCma = Math.max(withCma, pages); var changes = new LinkedHashMap(); changes.put("pool_want", Long.toString(pages)); @@ -607,11 +727,17 @@ Result saveTargets(long pages, long withCma) { if (withCma >= 0) changes.putAll(cmaTargetChanges(withCma)); var persisted = updateSettings(changes); if (!persisted.ok()) return Result.failed("settings", persisted.error); - var live = writeKnob("pool_want", Long.toString(pages)); - boolean liveOk = live.ok(); - if (withCma >= 0) - liveOk &= writeKnob("pool_want_with_cma", Long.toString(withCma)).ok(); - return Result.ok(liveOk ? "pool_want" : "settings", !liveOk); + return Result.ok("settings", false); // saved for next boot, not applied now + } + + /** Read a single-value sysfs knob as a long; fallback if absent/unparseable. */ + private static long readKnobLong(@NonNull String knob, long fallback) { + var t = safeRead(pathJoin(SYSFS_PARAMS, knob)).trim(); + if (!t.isEmpty()) try { + return Long.parseLong(t); + } catch (NumberFormatException ignored) { + } + return fallback; } @NonNull @@ -939,7 +1065,10 @@ private long poolTarget() { private Try> usageFromKo() { if (!koAvailable()) return Try.fail(Source.KO, "KO attribution knobs absent"); try { - // Reconcile, then read reconciled per-owner live page counts. + // Reconcile, then read the per-owner live page counts. One code + // path spans both module generations: the pre-v12 module computes + // these stats inside the reconcile pass (the write is required), + // while v12 keeps them live and accepts reconcile as a no-op. run("echo 1 > %s/reconcile", SYSFS_PARAMS); var livePages = new LinkedHashMap(); for (var ln : safeRead(pathJoin(SYSFS_PARAMS, "served_summary")).split("\n")) { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcess.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcess.java index 406faec5..e953bce1 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcess.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcess.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.ui.hugepage; import androidx.annotation.NonNull; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessActivity.java index 53f4e980..dccc4d35 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessActivity.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.ui.hugepage; import static android.view.View.GONE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessAdapter.java index 1e320649..51fe9923 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/HugePageProcessAdapter.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.ui.hugepage; import static android.view.View.GONE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/SegmentedBar.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/SegmentedBar.java index e81bcd86..5888c11d 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/SegmentedBar.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/SegmentedBar.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.ui.hugepage; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/logs/LogAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/logs/LogAdapter.java index 94ba80d7..23b1468a 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/logs/LogAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/logs/LogAdapter.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.ui.logs; import android.view.LayoutInflater; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/logs/LogViewHolder.java b/app/src/main/java/cn/classfun/droidvm/ui/logs/LogViewHolder.java index 3b39c875..7cfbb66c 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/logs/LogViewHolder.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/logs/LogViewHolder.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.ui.logs; import android.view.View; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/logs/LogsActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/logs/LogsActivity.java index 0292a894..5b10d255 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/logs/LogsActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/logs/LogsActivity.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.ui.logs; import static java.nio.charset.StandardCharsets.UTF_8; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/MainActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/main/MainActivity.java index 26144f4f..85cdb263 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/MainActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/MainActivity.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.ui.main; import static android.content.Intent.ACTION_VIEW; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/base/BaseViewHolder.java b/app/src/main/java/cn/classfun/droidvm/ui/main/base/BaseViewHolder.java index e99d11f9..fe9e9c61 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/base/BaseViewHolder.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/base/BaseViewHolder.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.ui.main.base; import android.view.View; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/base/MainBaseFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/base/MainBaseFragment.java index d746e1a9..583012f5 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/base/MainBaseFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/base/MainBaseFragment.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.ui.main.base; import android.os.Bundle; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/base/MainFragmentEnum.java b/app/src/main/java/cn/classfun/droidvm/ui/main/base/MainFragmentEnum.java index 6f8f0a1d..c5ed5315 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/base/MainFragmentEnum.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/base/MainFragmentEnum.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.ui.main.base; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/base/list/DataAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/main/base/list/DataAdapter.java index 4aa23645..8e168c56 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/base/list/DataAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/base/list/DataAdapter.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.ui.main.base.list; import android.annotation.SuppressLint; @@ -51,9 +54,19 @@ public final BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int vi return new BaseViewHolder(v); } + /** + * The config shown at an adapter position. Defaults to store order; adapters that reorder + * or hide rows (e.g. the disk overlay tree) override this together with + * {@link #getItemCount()}. + */ + @NonNull + protected D itemAt(int position) { + return items.get(position); + } + @Override public void onBindViewHolder(@NonNull BaseViewHolder h, int position) { - var d = items.get(position); + var d = itemAt(position); var ctx = h.itemView.getContext(); var drawable = AppCompatResources.getDrawable(ctx, getIconResId(d)); h.itemIcon.setImageDrawable(drawable); @@ -79,7 +92,7 @@ public void onItemsUpdated() { } @Override - public final int getItemCount() { + public int getItemCount() { return items.size(); } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/base/list/MainListFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/base/list/MainListFragment.java index b64ac47e..afb1385b 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/base/list/MainListFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/base/list/MainListFragment.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.ui.main.base.list; import static android.view.View.GONE; @@ -65,11 +68,16 @@ private void onItemClick(@NonNull View v, @NonNull D config) { private boolean onItemLongClick(@NonNull View v, @NonNull D config) { var pop = new MaterialMenu(requireContext(), v); pop.inflate(getItemMenuResId(config)); + onPrepareItemMenu(config, pop); pop.setOnMenuItemClickListener(item -> onMenuClicked(config, item)); pop.showAtTouch(lastTouchX, 0); return true; } + /** Hook to hide or tweak items of the long-press menu for this particular {@code config}. */ + protected void onPrepareItemMenu(@NonNull D config, @NonNull MaterialMenu menu) { + } + @NonNull private A createAdapter() { try { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/base/stateful/MainStatefulFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/base/stateful/MainStatefulFragment.java index 3e6cb949..d76b3c9d 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/base/stateful/MainStatefulFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/base/stateful/MainStatefulFragment.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.ui.main.base.stateful; import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/base/stateful/StatefulAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/main/base/stateful/StatefulAdapter.java index 15392970..3fd4f534 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/base/stateful/StatefulAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/base/stateful/StatefulAdapter.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.ui.main.base.stateful; import static android.view.View.VISIBLE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/disk/DiskAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/main/disk/DiskAdapter.java index e97e932b..354c6c82 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/disk/DiskAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/disk/DiskAdapter.java @@ -1,16 +1,27 @@ +// 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.ui.main.disk; +import static android.view.View.GONE; import static android.view.View.VISIBLE; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import static cn.classfun.droidvm.lib.size.SizeUtils.formatSize; +import android.annotation.SuppressLint; import android.content.Context; import android.util.Log; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; +import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -19,21 +30,59 @@ import cn.classfun.droidvm.lib.store.disk.DiskStore; import cn.classfun.droidvm.lib.utils.ImageUtils; import cn.classfun.droidvm.ui.disk.create.DiskFormat; +import cn.classfun.droidvm.ui.disk.tree.DiskTree; +import cn.classfun.droidvm.ui.disk.tree.DiskTreeCollapse; import cn.classfun.droidvm.ui.main.base.BaseViewHolder; import cn.classfun.droidvm.ui.main.base.list.DataAdapter; public final class DiskAdapter extends DataAdapter { + private static final int INDENT_DP = 16; + private static final int MAX_INDENT_STEPS = 4; + private final Map infoCache = new HashMap<>(); private final ExecutorService executor = Executors.newSingleThreadExecutor(); + // The overlay forest flattened for display: rows follow tree order, collapsed subtrees are + // hidden. Rebuilt from the store on every refresh; collapse choices persist via + // DiskTreeCollapse (injected context - the reflective adapter construction has none). + private final List flat = new ArrayList<>(); + private final Set collapsed = new HashSet<>(); + @Nullable + private Context appContext; public DiskAdapter() { super(DiskStore.class); } + /** Called once by the fragment; enables collapse persistence. */ + public void attachContext(@NonNull Context context) { + appContext = context.getApplicationContext(); + collapsed.clear(); + collapsed.addAll(DiskTreeCollapse.load(appContext)); + rebuildFlat(); + } + @Override public void onItemsUpdated() { - super.onItemsUpdated(); infoCache.clear(); + rebuildFlat(); + super.onItemsUpdated(); + } + + @SuppressLint("NotifyDataSetChanged") + private void rebuildFlat() { + flat.clear(); + flat.addAll(DiskTree.flatten(DiskTree.buildForest(items), collapsed)); + } + + @NonNull + @Override + protected DiskConfig itemAt(int position) { + return flat.get(position).config; + } + + @Override + public int getItemCount() { + return flat.size(); } @Override @@ -45,7 +94,8 @@ public int getIconResId(@NonNull DiskConfig disk) { @Override public void onBindViewHolder(@NonNull BaseViewHolder h, int position) { - var d = items.get(position); + var node = flat.get(position); + var d = node.config; h.itemCenter.setVisibility(VISIBLE); h.itemCenter.setText(d.item.optString("folder", "")); final Context ctx = h.itemView.getContext(); @@ -68,6 +118,53 @@ public void onBindViewHolder(@NonNull BaseViewHolder h, int position) { d.getFormat().name() )); super.onBindViewHolder(h, position); + bindTreeChrome(h, node, ctx); + } + + // Overlay-tree adornments: indent by depth, padlock start-drawable on a locked (has-children) + // name, chevron on the action button toggling collapse, "+N" state badge while collapsed, + // warning badge on a broken parent link. + private void bindTreeChrome( + @NonNull BaseViewHolder h, @NonNull DiskTree.Node node, @NonNull Context ctx) { + float density = ctx.getResources().getDisplayMetrics().density; + int steps = Math.min(node.depth, MAX_INDENT_STEPS); + h.itemView.setPaddingRelative( + Math.round((8 + steps * INDENT_DP) * density), + h.itemView.getPaddingTop(), + Math.round(8 * density), + h.itemView.getPaddingBottom()); + + h.itemName.setCompoundDrawablesRelativeWithIntrinsicBounds( + 0, 0, node.hasChildren() ? R.drawable.ic_lock : 0, 0); + + boolean isCollapsed = collapsed.contains(node.id()); + if (node.hasChildren()) { + h.itemAction.setVisibility(VISIBLE); + h.itemAction.setImageResource(R.drawable.ic_expand_more); + h.itemAction.setRotation(isCollapsed ? -90 : 0); + h.itemAction.setOnClickListener(v -> toggleCollapse(node.id())); + } else { + h.itemAction.setVisibility(GONE); + h.itemAction.setOnClickListener(null); + } + + if (node.brokenParent) { + h.itemState.setVisibility(VISIBLE); + h.itemState.setText(R.string.disk_tree_broken_parent); + } else if (isCollapsed && node.hasChildren()) { + h.itemState.setVisibility(VISIBLE); + h.itemState.setText(fmt("+%d", node.countDescendants())); + } else { + h.itemState.setVisibility(GONE); + } + } + + @SuppressLint("NotifyDataSetChanged") + private void toggleCollapse(@NonNull UUID id) { + if (!collapsed.remove(id)) collapsed.add(id); + if (appContext != null) DiskTreeCollapse.save(appContext, collapsed); + rebuildFlat(); + notifyDataSetChanged(); } private void loadImageInfoAsync(String path, int position) { @@ -83,7 +180,8 @@ private void loadImageInfoAsync(String path, int position) { var result = new ImageInfo(virtualSize, actualSize); mainHandler.post(() -> { infoCache.put(path, result); - if (position < items.size() && path.equals(items.get(position).getFullPath())) { + if (position < flat.size() + && path.equals(flat.get(position).config.getFullPath())) { try { notifyItemChanged(position); } catch (Exception ignored) { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/disk/ImageInfo.java b/app/src/main/java/cn/classfun/droidvm/ui/main/disk/ImageInfo.java index 3085297f..f1f64f5e 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/disk/ImageInfo.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/disk/ImageInfo.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.ui.main.disk; final class ImageInfo { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/disk/MainDiskFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/disk/MainDiskFragment.java index f4174e07..84281714 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/disk/MainDiskFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/disk/MainDiskFragment.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.ui.main.disk; import android.app.Activity; @@ -58,10 +61,14 @@ protected Class getInfoActivity() { @Override protected int getItemMenuResId(@NonNull DiskConfig config) { - var fmt = config.getFormat(); - if (!DiskConfig.supportsExtraOperations(fmt)) - return R.menu.menu_disk_actions_simple; - return R.menu.menu_disk_actions; + return DiskActionDialog.getMenuResId(config); + } + + @Override + protected void onPrepareItemMenu(@NonNull DiskConfig config, @NonNull MaterialMenu menu) { + // Reset is for writable leaf overlays only; a base with overlays never shows it. + menu.setItemVisible(R.id.menu_disk_reset, + config.getParentId() != null && !adapter.items.hasChildren(config.getId())); } @Override @@ -78,4 +85,11 @@ public void onCreate(@Nullable Bundle savedInstanceState) { new OpenDocument(), uri -> dialog.onFileImported(uri) ); } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + // Enables collapse persistence for the overlay tree. + adapter.attachContext(requireContext()); + } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/home/MainHomeFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/home/MainHomeFragment.java index 4eaedd55..272cd446 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/home/MainHomeFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/home/MainHomeFragment.java @@ -1,7 +1,9 @@ +// 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.ui.main.home; import static android.content.Intent.ACTION_VIEW; -import static android.widget.Toast.LENGTH_SHORT; import static cn.classfun.droidvm.lib.Constants.GITHUB_ISSUE_URL; import static cn.classfun.droidvm.lib.size.SizeUtils.formatSize; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; @@ -14,7 +16,6 @@ import android.view.View; import android.widget.ImageView; import android.widget.TextView; -import android.widget.Toast; import androidx.annotation.MenuRes; import androidx.annotation.NonNull; @@ -44,6 +45,7 @@ import cn.classfun.droidvm.ui.update.UpdateDialog; import cn.classfun.droidvm.ui.update.UpdateInfo; import cn.classfun.droidvm.ui.update.VersionCheck; +import cn.classfun.droidvm.ui.vm.VMCreateMenu; import cn.classfun.droidvm.ui.vm.pkg.imports.VMPkgImportActivity; public final class MainHomeFragment extends MainBaseFragment @@ -339,7 +341,7 @@ private void navigateToTab(int navId) { } private void openWizard() { - Toast.makeText(requireContext(), R.string.unimplement, LENGTH_SHORT).show(); + VMCreateMenu.show(requireContext()); } private void openImport() { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/network/MainNetworkFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/network/MainNetworkFragment.java index c340258f..4f13bdaf 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/network/MainNetworkFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/network/MainNetworkFragment.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.ui.main.network; import static android.widget.Toast.LENGTH_LONG; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/network/NetworkAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/main/network/NetworkAdapter.java index 5aeb08da..d02eb48b 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/network/NetworkAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/network/NetworkAdapter.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.ui.main.network; import static android.view.View.VISIBLE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiManagerDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiManagerDialog.java index e70b43af..f2e565cd 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiManagerDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiManagerDialog.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.ui.main.settings; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiServiceAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiServiceAdapter.java index 7632fe3a..48d1ab6a 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiServiceAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiServiceAdapter.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.ui.main.settings; import android.view.LayoutInflater; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiServiceViewHolder.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiServiceViewHolder.java index 8b35ee3c..4ffd0c27 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiServiceViewHolder.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/ApiServiceViewHolder.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.ui.main.settings; import android.view.View; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleDescriptions.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleDescriptions.java new file mode 100644 index 00000000..db53c121 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleDescriptions.java @@ -0,0 +1,213 @@ +// 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.ui.main.settings; + +import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.color.MaterialColors; + +import java.io.File; +import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; + +import cn.classfun.droidvm.R; + +/** + * The "why is this needed" pages behind each Kernel Module card. + * + *

    A page is one self-contained HTML file per module, carrying every language and no + * colours of its own; this class picks the language and paints in the live Material palette at + * display time, by injecting CSS ahead of the page's own stylesheet. That is why the shipped + * files are neither per-language nor per-theme -- see {@code gunyah_host_mod/descr/}. + * + *

    Pages for the shipped {@code .ko} modules come from {@code usr/lib/modules/descr/}, staged + * next to the modules themselves; GH-Hugepage-Reserve is a separately installed Magisk module + * with no prebuilt to ride on, so its page ships in the APK assets. Both are read once into + * memory ({@link #preload()}, off the main thread) -- they are a few KB each, and the card's + * why-button must know whether a page exists before it can decide to show itself. + */ +final class KernelModuleDescriptions { + private static final String DESCR_DIR = pathJoin(DATA_DIR, "usr", "lib", "modules", "descr"); + private static final String ASSET_DIR = "descr"; + private static final String HUGEPAGE_PAGE = "gh_hugepage_reserve"; + /** The app's stylesheet replaces this line; see the build's matching check. */ + private static final Pattern CSS_LINK = Pattern.compile("]+style\\.css[^>]*>"); + /** Pages tag their per-language blocks with this; used to test a language is present. */ + private static final String LANG_ATTR = "data-lang=\"%s\""; + private static final String FALLBACK_LANG = "en"; + + /** module-name prefix -> raw page HTML. Populated once by {@link #preload()}. */ + private static final Map PAGES = new HashMap<>(); + @Nullable + private static volatile String css; + private static volatile boolean loadTried; + + private KernelModuleDescriptions() { + } + + /** Read the pages and stylesheet once. Does file I/O: call off the main thread. */ + static synchronized void preload(@NonNull Context ctx) { + if (loadTried) return; + loadTried = true; + css = readAsset(ctx, fmt("%s/style.css", ASSET_DIR)); + var hugepage = readAsset(ctx, fmt("%s/%s.html", ASSET_DIR, HUGEPAGE_PAGE)); + if (hugepage != null) PAGES.put(HUGEPAGE_PAGE, hugepage); + var files = new File(DESCR_DIR).listFiles((d, n) -> n.endsWith(".html")); + if (files == null) return; // prebuilts not extracted yet -- why-buttons just stay hidden + for (var f : files) { + var html = readFile(f); + if (html != null) + PAGES.put(f.getName().substring(0, f.getName().length() - 5), html); + } + } + + /** Is there a page for this module? Cheap; safe on the main thread after preload. */ + static boolean hasModulePage(@NonNull String moduleName) { + return matchModule(moduleName) != null; + } + + static boolean hasHugepagePage() { + return PAGES.containsKey(HUGEPAGE_PAGE); + } + + /** + * Display-ready HTML for a module named as the card names it (normalized .ko basename, e.g. + * {@code udmabuf_gki_6.6}), or null if no page covers it. + */ + @Nullable + static String modulePage(@NonNull Context ctx, @NonNull String moduleName) { + var raw = matchModule(moduleName); + return raw == null ? null : prepare(ctx, raw); + } + + @Nullable + static String hugepagePage(@NonNull Context ctx) { + var raw = PAGES.get(HUGEPAGE_PAGE); + return raw == null ? null : prepare(ctx, raw); + } + + /** + * Page whose file name is the longest prefix of this module's name -- so a family page + * ({@code udmabuf.html}) covers every KMI build of it, and a page named for a specific + * build would still win over the family one. + */ + @Nullable + private static String matchModule(@NonNull String moduleName) { + String best = null; + int bestLen = -1; + for (var e : PAGES.entrySet()) { + var key = e.getKey(); + if (key.length() > bestLen && !HUGEPAGE_PAGE.equals(key) && moduleName.startsWith(key)) { + best = e.getValue(); + bestLen = key.length(); + } + } + return best; + } + + /** + * Swap the page's stylesheet link for: the app's copy of that stylesheet, then the live + * theme as CSS variables and the rule that reveals one language. Everything the page needs + * is inside the document, so the WebView loads no subresources at all. + * + *

    Order matters. The app's block comes after the stylesheet because the stylesheet + * carries a standalone fallback palette (so a page can be proofread in a browser) -- injected + * first, that fallback would win and the page would ignore the app's real theme. + */ + @NonNull + private static String prepare(@NonNull Context ctx, @NonNull String rawHtml) { + boolean dark = isDark(ctx); + int ink = dark ? 0xFFE2E2E6 : 0xFF1A1C1E; + int paper = dark ? 0xFF1A1C1E : 0xFFFDFCFF; + var injected = new StringBuilder("\n"); + var html = CSS_LINK.matcher(rawHtml).replaceFirst(java.util.regex.Matcher + .quoteReplacement(injected.toString())); + // Structural dark-mode branching for anything colours alone cannot express. + return html.replaceFirst("A {@link DialogFragment}, not a bare {@code AlertDialog.show()}: the fragment manager owns + * it, so it is dismissed and reshown cleanly with the settings screen rather than leaking on a + * back-stack change. + * + *

    Fitting the window to the orientation

    + * The list is tall; a phone held sideways has little height, and M3's fixed 80dp top+bottom + * background inset then leaves the scrolling area a single row. The activities here all set + * {@code android:configChanges="orientation|screenSize|..."}, so a rotation does not + * recreate the activity or rebuild this dialog -- it arrives as {@link #onConfigurationChanged}. + * A build-time inset (set once in {@link #onCreateDialog}) would therefore keep its original + * orientation's value forever. So the height is driven from {@link #applyWindowMetrics}, called + * both when the dialog first shows and on every configuration change: landscape gives the window + * almost the whole screen height (the list scrolls within it), portrait lets it wrap. + */ +public final class KernelModuleDialog extends DialogFragment { + private static final String TAG = "kernel_modules"; + /** Landscape: fraction of screen height the dialog takes; the rest is a thin margin. */ + private static final float LANDSCAPE_HEIGHT_FRACTION = 0.94f; + + /** Show the dialog, or do nothing if it is already showing (e.g. a double tap). */ + public static void show(@NonNull FragmentManager fm) { + if (fm.findFragmentByTag(TAG) == null) new KernelModuleDialog().show(fm, TAG); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + var ctx = requireContext(); + var content = LayoutInflater.from(ctx).inflate(R.layout.dialog_kernel_modules, null); + + var dialog = new MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.kernel_module_title) + .setView(content) + .setPositiveButton(android.R.string.ok, null) + .create(); + + // The view is inflated; refresh can find its ids. Runs off the main thread and posts + // back, so it is fine to kick off before the dialog is shown -- same as a fresh open. + new KernelModuleListController(ctx, content).refresh(); + return dialog; + } + + @Override + public void onStart() { + super.onStart(); + applyWindowMetrics(); + } + + @Override + public void onConfigurationChanged(@NonNull Configuration newConfig) { + super.onConfigurationChanged(newConfig); + // A rotation lands here (configChanges keeps the activity alive), not in onCreateDialog. + applyWindowMetrics(); + } + + /** + * Size the dialog window to the current orientation. Only the height is touched -- the width + * keeps whatever M3 chose (centred, its own horizontal insets). Landscape claims nearly the + * full height so the module list is not squeezed to a row; portrait wraps its content as a + * dialog normally would. Runs on show and on every configuration change, so a rotate with + * the dialog open re-fits it instead of leaving a stale portrait/landscape size. + */ + private void applyWindowMetrics() { + var dialog = getDialog(); + if (dialog == null) return; + Window window = dialog.getWindow(); + if (window == null) return; + var res = getResources(); + boolean landscape = + res.getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + var lp = window.getAttributes(); + lp.height = landscape + ? Math.round(res.getDisplayMetrics().heightPixels * LANDSCAPE_HEIGHT_FRACTION) + : WindowManager.LayoutParams.WRAP_CONTENT; + window.setAttributes(lp); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleListController.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleListController.java new file mode 100644 index 00000000..6b5aa24a --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleListController.java @@ -0,0 +1,212 @@ +// 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.ui.main.settings; + +import static cn.classfun.droidvm.lib.utils.FileUtils.shellCheckExists; +import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; + +import android.content.Context; +import android.content.Intent; +import android.content.res.ColorStateList; +import android.graphics.Typeface; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.material.color.MaterialColors; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.materialswitch.MaterialSwitch; + +import java.util.List; +import java.util.function.Supplier; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.ui.hugepage.HugePageActivity; + +/** + * Renders the host kernel module list into any view carrying {@code km_kmi}/{@code km_state}/ + * {@code km_list} (the {@code dialog_kernel_modules} layout) -- the settings dialog and the + * first-run setup step both show the same list through this. Each shipped module + * ({@link KernelModuleManager}) gets a card with load/unload and an auto-load-at-app-start + * switch; a GH-Hugepage-Reserve entry is always pinned first: that module is a separately + * installed Magisk/KernelSU package (it must load at early boot), so its card offers + * Download/Manage instead. Every card has a "why is this needed" button showing the + * plain-language description from {@link KernelModuleDescriptions}. The module scan and + * insmod/rmmod all touch root, so they run on a background pool; the view is (re)built on the + * main thread. + */ +public final class KernelModuleListController { + private static final String HUGEPAGE_PROP = "/data/adb/modules/gh-hugepage-reserve/module.prop"; + private static final String HUGEPAGE_SYSFS = "/sys/module/gh_hugepage_reserve"; + private static final String HUGEPAGE_RELEASES = + "https://github.com/Droid-VM/gh-hugepage-reserve/releases"; + + private final Context ctx; + private final Handler main = new Handler(Looper.getMainLooper()); + private final LayoutInflater inflater; + private final TextView kmi, state; + private final LinearLayout list; + + public KernelModuleListController(@NonNull Context ctx, @NonNull View content) { + this.ctx = ctx; + this.inflater = LayoutInflater.from(ctx); + kmi = content.findViewById(R.id.km_kmi); + state = content.findViewById(R.id.km_state); + list = content.findViewById(R.id.km_list); + } + + public void refresh() { + runOnPool(() -> { + // Explicitly, not via list(): with no modules directory list() returns before it + // would have loaded the rules, and the hugepage card still needs them. + KernelModuleMatch.preload(ctx); + var kmiName = KernelModuleManager.deviceKmi(); + var mods = KernelModuleManager.list(ctx); + KernelModuleDescriptions.preload(ctx); + // GH-Hugepage-Reserve is matched like any other module; on a device it does not + // apply to, the card is absent rather than offering a download that cannot help. + boolean hpApplies = KernelModuleMatch.allowsHugepage(); + // Failing to see /data/adb (no root, no Magisk) reads as false -> Download, + // which is the wanted "can't tell whether it's installed" answer. + boolean hpInstalled = hpApplies && shellCheckExists(HUGEPAGE_PROP); + boolean hpLoaded = hpApplies && shellCheckExists(HUGEPAGE_SYSFS); + main.post(() -> render(kmiName, mods, hpApplies, hpInstalled, hpLoaded)); + }); + } + + private void render(@Nullable String kmiName, @NonNull List mods, + boolean hpApplies, boolean hpInstalled, boolean hpLoaded) { + if (list == null) return; + if (kmiName != null) { + kmi.setText(ctx.getString(R.string.kernel_module_kmi, kmiName)); + kmi.setVisibility(View.VISIBLE); + } + list.removeAllViews(); + if (hpApplies) list.addView(buildHugepageCard(hpInstalled, hpLoaded)); + for (var mod : mods) list.addView(buildCard(mod)); + // "None" means nothing at all applies to this device (a non-Qualcomm phone, say), not + // merely that the .ko list under an applicable hugepage card happens to be empty. + boolean empty = mods.isEmpty() && !hpApplies; + state.setText(R.string.kernel_module_none); + state.setVisibility(empty ? View.VISIBLE : View.GONE); + } + + @NonNull + private View buildCard(@NonNull KernelModuleManager.Module mod) { + var card = inflater.inflate(R.layout.item_kernel_module, list, false); + TextView name = card.findViewById(R.id.km_name); + Button toggle = card.findViewById(R.id.km_toggle); + MaterialSwitch autostart = card.findViewById(R.id.km_autostart); + + name.setText(mod.display); + bindStatus(card, mod.loaded); + bindWhy(card, mod.display, KernelModuleDescriptions.hasModulePage(mod.name), + () -> KernelModuleDescriptions.modulePage(ctx, mod.name)); + + toggle.setText(mod.loaded ? R.string.kernel_module_unload : R.string.kernel_module_load); + toggle.setOnClickListener(v -> { + v.setEnabled(false); + doToggle(mod); + }); + + // Arming autostart requires the module to have been seen loaded. The switch stays + // pressable so the refusal can explain itself -- a greyed-out control just looks broken. + // Rejected flips it back to off, and that second callback is a no-op (disarming is + // always allowed), so there is no loop. + autostart.setChecked(KernelModuleManager.isAutostart(ctx, mod.name) + && KernelModuleManager.isVerified(mod.name)); + autostart.setOnCheckedChangeListener((b, checked) -> { + if (!KernelModuleManager.setAutostart(ctx, mod.name, checked)) { + b.setChecked(false); + Toast.makeText(ctx, R.string.kernel_module_autostart_needs_load, + Toast.LENGTH_SHORT).show(); + } + }); + + return card; + } + + /** + * GH-Hugepage-Reserve is not one of the shipped .ko files: it installs as a Magisk/KernelSU + * module so it can load at early boot, before memory fragments. Its card therefore has no + * load/unload or autostart -- just Download (releases page) or, once installed, Manage + * (the dedicated management screen, which owns enable/pool-size/attribution). + */ + @NonNull + private View buildHugepageCard(boolean installed, boolean loaded) { + var card = inflater.inflate(R.layout.item_kernel_module, list, false); + TextView name = card.findViewById(R.id.km_name); + Button action = card.findViewById(R.id.km_toggle); + + name.setText(R.string.kernel_module_hugepage_name); + bindStatus(card, loaded); + bindWhy(card, ctx.getString(R.string.kernel_module_hugepage_name), + KernelModuleDescriptions.hasHugepagePage(), + () -> KernelModuleDescriptions.hugepagePage(ctx)); + + action.setText(installed ? R.string.kernel_module_manage : R.string.hugepage_download); + action.setOnClickListener(installed + ? v -> ctx.startActivity(new Intent(ctx, HugePageActivity.class)) + : v -> ctx.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(HUGEPAGE_RELEASES)))); + + card.findViewById(R.id.km_auto_row).setVisibility(View.GONE); + return card; + } + + /** Status line: loaded = theme primary, not loaded = bold in the theme's error red. */ + private void bindStatus(@NonNull View card, boolean loaded) { + TextView status = card.findViewById(R.id.km_status); + ImageView dot = card.findViewById(R.id.km_dot); + status.setText(loaded ? R.string.kernel_module_loaded : R.string.kernel_module_unloaded); + status.setTypeface(null, loaded ? Typeface.NORMAL : Typeface.BOLD); + int tint = MaterialColors.getColor(card, loaded + ? androidx.appcompat.R.attr.colorPrimary + : androidx.appcompat.R.attr.colorError); + status.setTextColor(tint); + dot.setImageTintList(ColorStateList.valueOf(tint)); + } + + /** + * "Why is this needed" opens the module's description page; with no page shipped for this + * module (an older prebuilt, or one not extracted yet) the button hides instead of opening + * an empty dialog. The page itself is only assembled on click -- {@code hasPage} is the + * cheap check the card can afford while building the list. + */ + private void bindWhy(@NonNull View card, @NonNull String title, boolean hasPage, + @NonNull Supplier page) { + Button why = card.findViewById(R.id.km_why); + if (!hasPage) { + why.setVisibility(View.GONE); + return; + } + why.setOnClickListener(v -> { + var html = page.get(); + if (html != null) KernelModuleWhyDialog.show(ctx, title, html); + }); + } + + private void doToggle(@NonNull KernelModuleManager.Module mod) { + Toast.makeText(ctx, R.string.kernel_module_working, Toast.LENGTH_SHORT).show(); + runOnPool(() -> { + boolean ok = mod.loaded + ? KernelModuleManager.unload(mod.name) + : KernelModuleManager.loadAndVerify(mod); + main.post(() -> { + Toast.makeText(ctx, ok ? R.string.kernel_module_ok : R.string.kernel_module_fail, + Toast.LENGTH_SHORT).show(); + refresh(); + }); + }); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleManager.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleManager.java new file mode 100644 index 00000000..a089d1c0 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleManager.java @@ -0,0 +1,239 @@ +// 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.ui.main.settings; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.File; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +import cn.classfun.droidvm.lib.utils.RunUtils; + +/** + * Manages the host kernel modules the app ships under {@code usr/lib/modules//} (currently the + * gunyah_share SHARE-blob module used by GuestAccept). Selects the {@code .ko} whose KMI directory + * matches the running kernel, loads/unloads it via root {@code insmod}/{@code rmmod}, and can + * auto-load a user-chosen set at app start. All exec goes through {@link RunUtils} (root/libsu), so + * every method here touches root and must be called off the main thread. + */ +public final class KernelModuleManager { + private static final String PREFS = "droidvm_prefs"; + private static final String KEY_AUTOSTART = "kernel_module_autostart"; + private static final String MODULES_ROOT = pathJoin(DATA_DIR, "usr", "lib", "modules"); + // Leading major.minor of `uname -r` (e.g. "6.6.30-android15-..." -> "6.6"), matched against the + // KMI dir names ("android15-6.6", "android16-6.12") to pick the right build for this kernel. + private static final Pattern KVER = Pattern.compile("^(\\d+\\.\\d+)"); + + /** + * Modules seen loaded on this device this session -- the gate on arming autostart. + * + * Deliberately in memory only. It is a hint, not a setting: the point is that a module must + * have demonstrably loaded before it is put in the daemon's startup path, and a fresh process + * can re-establish that for free from /proc/modules (see list()). Persisting it would outlive + * the kernel it was true for -- an OTA changes the KMI and the claim silently stops holding. + */ + private static final Set VERIFIED = ConcurrentHashMap.newKeySet(); + + private KernelModuleManager() { + } + + public static final class Module { + public final String name; // normalized module name, e.g. "gunyah_host_share_gki_6.6" + public final String display; // human title for the card; name stays the load/prefs key + public final String path; // absolute .ko path for this device's KMI + public final boolean loaded; + + Module(@NonNull String name, @NonNull String display, @NonNull String path, + boolean loaded) { + this.name = name; + this.display = display; + this.path = path; + this.loaded = loaded; + } + } + + // The per-KMI suffix disambiguates the .ko files and /sys/module entries, nothing more. + // The list header already states the KMI, so a module nobody named still displays without it. + private static final Pattern GKI_SUFFIX = Pattern.compile("_gki_\\d+\\.\\d+$"); + + /** Card title: the {@code names} entry from match.json, else the name minus its KMI tag. */ + @NonNull + private static String displayNameFor(@NonNull String name) { + var explicit = KernelModuleMatch.displayName(name); + return explicit != null ? explicit : GKI_SUFFIX.matcher(name).replaceFirst(""); + } + + /** The {@code modules/} dir whose name embeds the running kernel's major.minor, else null. */ + @Nullable + private static File deviceKmiDir() { + var subs = new File(MODULES_ROOT).listFiles(File::isDirectory); + if (subs == null || subs.length == 0) return null; + var m = KVER.matcher(RunUtils.runList("uname", "-r").getOutString().trim()); + if (m.find()) { + String mmver = m.group(1); + // Match the version as a whole "-" token, not a substring: a plain + // contains() would let a 6.1 kernel match an "android16-6.12" dir ("6.12" contains + // "6.1"). Require the char after the version to be a non-digit or end-of-name. + var quoted = Pattern.quote(fmt("-%s", mmver)); + var tok = Pattern.compile(fmt("%s(\\D|$)", quoted)); + for (var d : subs) + if (tok.matcher(d.getName()).find()) return d; + } + return subs[0]; // fallback: single/first KMI dir + } + + /** Name of the KMI dir picked for this device (e.g. "android15-6.6"), or null. Runs root exec. */ + @Nullable + public static String deviceKmi() { + var d = deviceKmiDir(); + return d == null ? null : d.getName(); + } + + /** Names of currently-loaded modules, from {@code /proc/modules} (needs root). */ + @NonNull + private static Set loadedNames() { + var set = new HashSet(); + var r = RunUtils.run("cat /proc/modules"); + if (r.isSuccess()) { + for (var line : r.getOutString().split("\n")) { + int sp = line.indexOf(' '); + if (sp > 0) set.add(line.substring(0, sp).trim()); + } + } + return set; + } + + /** + * Shipped modules that apply to this device: the KMI directory selects the ones built for this + * kernel, {@link KernelModuleMatch} then drops the ones built for a different SoC. Both halves + * are needed -- a Gunyah module is a matching KMI and a wrong device on a MediaTek phone. + */ + @NonNull + public static List list(@NonNull Context ctx) { + var out = new ArrayList(); + var dir = deviceKmiDir(); + if (dir == null) return out; + var kos = dir.listFiles((d, n) -> n.endsWith(".ko")); + if (kos == null) return out; + KernelModuleMatch.preload(ctx); + var loaded = loadedNames(); + for (var ko : kos) { + String base = ko.getName().substring(0, ko.getName().length() - 3); + String name = base.replace('-', '_'); // /proc/modules uses underscores + if (!KernelModuleMatch.allows(name)) continue; + boolean isLoaded = loaded.contains(name); + // Already loaded -- however it got there, autostart is answerable for it now. + if (isLoaded) VERIFIED.add(name); + out.add(new Module(name, displayNameFor(name), ko.getAbsolutePath(), isLoaded)); + } + return out; + } + + /** {@code insmod} the module; true on success. */ + public static boolean load(@NonNull String path) { + return RunUtils.run("insmod %s%s", RunUtils.escapedString(path), insmodArgsFor(path)) + .isSuccess(); + } + + /** + * Extra {@code insmod} parameters for modules that need per-device runtime configuration. + * + * nproc_guard must be told which app uid to guard: it differs per device (and per user + * profile), so it is not hardcoded, and a bare insmod leaves the module inert. Pass this + * app's own uid -- the same uid crosvm and Zygote run the app's processes as, and the one + * whose RLIMIT_NPROC ucounts counter can wedge. + */ + @NonNull + private static String insmodArgsFor(@NonNull String path) { + if (new File(path).getName().startsWith("nproc-guard")) + return fmt(" uid=%d", android.os.Process.myUid()); + return ""; + } + + /** + * {@code insmod} and, on success, record that this module loads on this device -- which is + * what {@link #setAutostart} requires before it will arm one. Use this for user-initiated + * loads; {@link #applyAutostart} deliberately does not, since a module can only get there by + * having been verified already. + */ + public static boolean loadAndVerify(@NonNull Module mod) { + if (!load(mod.path)) return false; + VERIFIED.add(mod.name); + return true; + } + + /** Has this module been seen loaded this session? Arming autostart requires it. */ + public static boolean isVerified(@NonNull String name) { + return VERIFIED.contains(name); + } + + /** {@code rmmod} the module by name; true on success. */ + public static boolean unload(@NonNull String name) { + return RunUtils.run("rmmod %s", name).isSuccess(); + } + + // ---- autostart persistence ---- + + @NonNull + private static SharedPreferences prefs(@NonNull Context ctx) { + return ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE); + } + + @NonNull + private static Set autostartSet(@NonNull Context ctx) { + // getStringSet's result must not be mutated; copy it. + return new HashSet<>(prefs(ctx).getStringSet(KEY_AUTOSTART, new HashSet<>())); + } + + public static boolean isAutostart(@NonNull Context ctx, @NonNull String name) { + return autostartSet(ctx).contains(name); + } + + /** + * Arm or disarm autostart. Arming is refused for a module that has never loaded here: the + * caller is expected to have greyed the control out, and this is the backstop that keeps an + * unverified module out of the boot path regardless. Returns false when refused. + */ + public static boolean setAutostart(@NonNull Context ctx, @NonNull String name, boolean enabled) { + if (enabled && !isVerified(name)) return false; + var set = autostartSet(ctx); + if (enabled) set.add(name); + else set.remove(name); + prefs(ctx).edit().putStringSet(KEY_AUTOSTART, set).apply(); + return true; + } + + /** + * Loads every autostart-enabled module that isn't already loaded. Call off the main thread. + * Safe to call repeatedly (skips already-loaded modules). + * + * nproc_guard (the no-reboot rescue for the app-launch wedge) is opt-in like any other module: + * not every device exhibits the RLIMIT_NPROC drift, so it is loaded only when the user enables + * it in the Kernel Module tab. That single toggle is the whole opt-in -- while it is loaded the + * daemon also nudges its reset after each VM stop; while it is not, that reset is a no-op + * (the sysfs node is absent), so the default is to do nothing. + */ + public static void applyAutostart(@NonNull Context ctx) { + var enabled = autostartSet(ctx); + if (enabled.isEmpty()) return; + for (var mod : list(ctx)) { + if (enabled.contains(mod.name) && !mod.loaded) + load(mod.path); + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleMatch.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleMatch.java new file mode 100644 index 00000000..f362bbd2 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleMatch.java @@ -0,0 +1,267 @@ +// 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.ui.main.settings; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import static cn.classfun.droidvm.lib.Constants.DATA_DIR; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; + +import android.content.Context; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.FileInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Locale; + +import cn.classfun.droidvm.lib.data.SocIdentity; + +/** + * Decides which host kernel modules apply to this device. + * + *

    The KMI directory already answers "which kernel", and that is only half the question: every + * module shipped so far is Gunyah/Qualcomm work that does nothing on a MediaTek or Tensor phone, + * and a module written for those later would be just as wrong here. So each module carries a match + * rule, and only modules whose rule passes are listed at all. + * + *

    Rules

    + * Rules live in {@code usr/lib/modules/match.json}, shipped next to the {@code .ko} files by the + * repo that builds them, so a new module for a new vendor needs no app change: + *
    + * { "version": 1,
    + *   "modules": {
    + *     "gunyah_host_share": { "soc_vendor": ["qualcomm"] },
    + *     "mtk_whatever":      { "soc_vendor": ["mediatek"], "soc_model_prefix": ["MT69"] } },
    + *   "names": {
    + *     "gunyah_host_share": "Gunyah Host Share" } }
    + * 
    + * The key is a module-name prefix (so {@code udmabuf} covers {@code udmabuf_gki_6.6}) and + * the longest matching key wins. Within a field the values are alternatives (OR); across fields + * every field must pass (AND). An empty rule matches everything. + * + *

    {@code names} maps the same prefixes to the human title the module's card shows + * ({@link #displayName}). It is a top-level section, not a rule field, on purpose: an unknown + * field inside a rule makes the rule fail (below), so an older app reading a newer + * file would stop listing every named module -- whereas it never looks at extra top-level keys. + * + *

    Supported fields: {@code soc_vendor} (tokens from {@link SocIdentity}), {@code soc_model} + * (exact, case-insensitive), {@code soc_model_prefix}. A field this app does not know makes the + * rule fail, rather than being skipped: a newer rule file narrowing a module by something we + * cannot evaluate must not end up listing it anyway -- an insmod on the wrong device is a kernel + * panic, so the safe default is to say no. + * + *

    The app also ships {@code assets/match.json} in the same format. It carries the rule for + * GH-Hugepage-Reserve -- which has no {@code .ko} and so no prebuilt to ride on -- and doubles as + * the answer for prebuilts older than {@code match.json}, which only ever contained Qualcomm + * modules. The device file wins where both describe a module. + */ +public final class KernelModuleMatch { + private static final String TAG = "KernelModuleMatch"; + private static final String DEVICE_RULES = + pathJoin(DATA_DIR, "usr", "lib", "modules", "match.json"); + private static final String BUILTIN_RULES = "match.json"; + /** GH-Hugepage-Reserve's key in the built-in rules; it ships no .ko to name it. */ + static final String HUGEPAGE = "gh_hugepage_reserve"; + + @Nullable + private static volatile JSONObject deviceRules; + @Nullable + private static volatile JSONObject builtinRules; + @Nullable + private static volatile JSONObject deviceNames; + @Nullable + private static volatile JSONObject builtinNames; + private static volatile boolean loadTried; + + private KernelModuleMatch() { + } + + /** Read both rule files once. Does file I/O and getprop: call off the main thread. */ + static synchronized void preload(@NonNull Context ctx) { + if (!loadTried) { + loadTried = true; + var builtin = parse(readAsset(ctx)); + builtinRules = builtin == null ? null : builtin.optJSONObject("modules"); + builtinNames = builtin == null ? null : builtin.optJSONObject("names"); + SocIdentity.vendor(); // warm the cache while we are off the main thread anyway + } + // The device file is retried until it is actually read, and only then latched. It lives + // in the extracted payload, so "not there" and "not there YET" are the same failure at + // this level -- readDeviceFile's own comment says so -- and latching the first attempt + // makes a temporary state permanent for the life of the process. That is what happened: + // something asked before the payload finished extracting, the rules stayed null, and + // every later call fell back to the built-in copy, which silently dropped nproc_guard + // from the module list because the built-in had no rule for it. + // + // The asset above is different and stays a one-shot: it ships inside the APK, so a + // failure there is real and will not fix itself by being asked again. + if (deviceRules == null) { + var device = parse(readDeviceFile()); + if (device != null) { + deviceRules = device.optJSONObject("modules"); + deviceNames = device.optJSONObject("names"); + } + } + } + + /** Does this module (normalized .ko basename) apply to this device? */ + static boolean allows(@NonNull String moduleName) { + var rule = ruleFor(moduleName); + if (rule == null) { + // Every shipped module has a rule (the build refuses otherwise), so this is a .ko we + // know nothing about. Saying no keeps a stray file out of a list whose buttons insmod. + Log.w(TAG, fmt("no match rule for %s; not listing it", moduleName)); + return false; + } + return eval(rule, moduleName); + } + + /** GH-Hugepage-Reserve is a Magisk module, not a .ko, but the same rules decide it. */ + static boolean allowsHugepage() { + var rule = ruleFor(HUGEPAGE); + return rule != null && eval(rule, HUGEPAGE); + } + + /** + * Is there anything here for this device at all? The setup wizard skips its kernel-module page + * entirely when there is not -- on a phone none of these modules were written for, the page + * would be a list of nothing with instructions to load it. Settings still opens the list; it + * just shows the empty state. Does I/O: call off the main thread. + */ + public static boolean anyApplicable(@NonNull Context ctx) { + preload(ctx); + return allowsHugepage() || !KernelModuleManager.list(ctx).isEmpty(); + } + + /** + * Human title for this module from the files' {@code names} sections, resolved like the + * rules (longest prefix, device file first), or null when neither file names it -- the + * caller then falls back to something derived from the module name itself. + */ + @Nullable + static String displayName(@NonNull String moduleName) { + var fromDevice = longestPrefixString(deviceNames, moduleName); + return fromDevice != null ? fromDevice : longestPrefixString(builtinNames, moduleName); + } + + /** Longest-prefix rule for this module: device file first, then the app's built-in. */ + @Nullable + private static JSONObject ruleFor(@NonNull String moduleName) { + var fromDevice = longestPrefix(deviceRules, moduleName); + return fromDevice != null ? fromDevice : longestPrefix(builtinRules, moduleName); + } + + @Nullable + private static String longestPrefixString(@Nullable JSONObject names, @NonNull String name) { + if (names == null) return null; + String best = null; + int bestLen = -1; + for (var it = names.keys(); it.hasNext(); ) { + var key = it.next(); + if (key.length() > bestLen && name.startsWith(key)) { + var value = names.optString(key); + if (!value.isEmpty()) { + best = value; + bestLen = key.length(); + } + } + } + return best; + } + + @Nullable + private static JSONObject longestPrefix(@Nullable JSONObject rules, @NonNull String name) { + if (rules == null) return null; + JSONObject best = null; + int bestLen = -1; + for (var it = rules.keys(); it.hasNext(); ) { + var key = it.next(); + if (key.length() > bestLen && name.startsWith(key)) { + var rule = rules.optJSONObject(key); + if (rule != null) { + best = rule; + bestLen = key.length(); + } + } + } + return best; + } + + private static boolean eval(@NonNull JSONObject rule, @NonNull String moduleName) { + for (var it = rule.keys(); it.hasNext(); ) { + var field = it.next(); + switch (field) { + case "soc_vendor": + if (!anyEquals(rule.optJSONArray(field), SocIdentity.vendor())) return false; + break; + case "soc_model": + if (!anyEquals(rule.optJSONArray(field), SocIdentity.model())) return false; + break; + case "soc_model_prefix": + if (!anyPrefixOf(rule.optJSONArray(field), SocIdentity.model())) return false; + break; + case "comment": + break; + default: + Log.w(TAG, fmt("rule for %s uses unknown field '%s'; this app cannot" + + " judge it, so not listing the module", moduleName, field)); + return false; + } + } + return true; + } + + private static boolean anyEquals(@Nullable JSONArray values, @NonNull String actual) { + if (values == null) return false; + for (int i = 0; i < values.length(); i++) + if (actual.equalsIgnoreCase(values.optString(i))) return true; + return false; + } + + private static boolean anyPrefixOf(@Nullable JSONArray values, @NonNull String actual) { + if (values == null) return false; + var lower = actual.toLowerCase(Locale.ROOT); + for (int i = 0; i < values.length(); i++) { + var p = values.optString(i); + if (!p.isEmpty() && lower.startsWith(p.toLowerCase(Locale.ROOT))) return true; + } + return false; + } + + @Nullable + private static JSONObject parse(@Nullable String json) { + if (json == null) return null; + try { + return new JSONObject(json); + } catch (Exception e) { + Log.w(TAG, "unparseable match rules", e); + return null; + } + } + + @Nullable + private static String readAsset(@NonNull Context ctx) { + try (var in = ctx.getAssets().open(BUILTIN_RULES)) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + Log.w(TAG, "no built-in match rules", e); + return null; + } + } + + @Nullable + private static String readDeviceFile() { + try (var in = new FileInputStream(DEVICE_RULES)) { + return new String(in.readAllBytes(), StandardCharsets.UTF_8); + } catch (Exception e) { + return null; // prebuilt predates match.json, or is not extracted yet + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleWhyDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleWhyDialog.java new file mode 100644 index 00000000..4020370f --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/KernelModuleWhyDialog.java @@ -0,0 +1,86 @@ +// 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.ui.main.settings; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.view.LayoutInflater; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.annotation.NonNull; + +import com.google.android.material.color.MaterialColors; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.ByteArrayInputStream; + +import cn.classfun.droidvm.R; + +/** + * Shows a module's "why is this needed" page ({@link KernelModuleDescriptions}) in a WebView, so + * the explanation can use real layout and inline SVG rather than the handful of tags a TextView + * understands. + * + *

    The page arrives as one self-contained document -- stylesheet inlined, theme and language + * already resolved -- so the WebView has nothing to fetch. Everything that could fetch is + * therefore turned off: no JavaScript, no file or content access, network loads blocked, and any + * request that still gets issued is answered with nothing. + */ +final class KernelModuleWhyDialog { + /** Tallest the page gets before it scrolls, as a fraction of the screen. */ + private static final float MAX_HEIGHT_FRACTION = 0.62f; + + private KernelModuleWhyDialog() { + } + + @SuppressLint("SetJavaScriptEnabled") // turning it OFF; lint flags the setter either way + static void show(@NonNull Context ctx, @NonNull String title, @NonNull String html) { + var content = LayoutInflater.from(ctx).inflate(R.layout.dialog_module_descr, null); + WebView web = content.findViewById(R.id.descr_web); + + var s = web.getSettings(); + s.setJavaScriptEnabled(false); + s.setAllowFileAccess(false); + s.setAllowContentAccess(false); + s.setBlockNetworkLoads(true); + s.setGeolocationEnabled(false); + // The page is authored for this width; don't let the WebView zoom out to a desktop one. + s.setLoadWithOverviewMode(false); + s.setUseWideViewPort(false); + + // A WebView starts white, which flashes against a dark dialog before the page paints. + web.setBackgroundColor(MaterialColors.getColor(content, + com.google.android.material.R.attr.colorSurface)); + web.setWebViewClient(new WebViewClient() { + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, + WebResourceRequest request) { + return new WebResourceResponse("text/plain", "utf-8", + new ByteArrayInputStream(new byte[0])); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + return true; // these pages have no links; never navigate away from the document + } + }); + + var lp = web.getLayoutParams(); + lp.height = (int) (ctx.getResources().getDisplayMetrics().heightPixels + * MAX_HEIGHT_FRACTION); + web.setLayoutParams(lp); + + web.loadDataWithBaseURL(null, html, "text/html", "utf-8", null); + + new MaterialAlertDialogBuilder(ctx) + .setTitle(title) + .setView(content) + .setPositiveButton(android.R.string.ok, null) + .setOnDismissListener(d -> web.destroy()) + .show(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/LicenseListAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/LicenseListAdapter.java index 5f7949bb..434a1328 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/LicenseListAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/LicenseListAdapter.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.ui.main.settings; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java index 617628e2..092f8817 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/MainSettingsFragment.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.ui.main.settings; import static android.content.Intent.ACTION_VIEW; @@ -22,11 +25,16 @@ import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.MenuRes; import androidx.annotation.NonNull; +import androidx.annotation.StringRes; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatDelegate; import androidx.core.os.LocaleListCompat; +import android.widget.RadioGroup; + import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.materialswitch.MaterialSwitch; +import com.google.android.material.radiobutton.MaterialRadioButton; import org.json.JSONException; import org.json.JSONObject; @@ -35,15 +43,15 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.text.SimpleDateFormat; +import java.util.ArrayList; import java.util.Date; -import java.util.HashSet; import java.util.Locale; -import java.util.Set; import cn.classfun.droidvm.BuildConfig; import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.api.ApiManager; import cn.classfun.droidvm.lib.api.Privacy; +import cn.classfun.droidvm.daemon.vm.UsbAcmPool; import cn.classfun.droidvm.lib.daemon.DaemonHelper; import cn.classfun.droidvm.lib.daemon.VMEventHandler; import cn.classfun.droidvm.lib.data.Language; @@ -54,6 +62,8 @@ import cn.classfun.droidvm.lib.store.vm.VMStore; import cn.classfun.droidvm.lib.ui.UIContext; import cn.classfun.droidvm.lib.utils.CpuUtils; +import cn.classfun.droidvm.ui.disk.create.DiskCompress; +import cn.classfun.droidvm.ui.disk.operation.OptimizeCompression; import cn.classfun.droidvm.ui.hugepage.HugePageActivity; import cn.classfun.droidvm.ui.main.base.MainBaseFragment; import cn.classfun.droidvm.ui.setup.SetupActivity; @@ -63,13 +73,16 @@ import cn.classfun.droidvm.ui.update.VersionCheck; import cn.classfun.droidvm.ui.widgets.row.SwitchRowWidget; import cn.classfun.droidvm.ui.widgets.row.TextRowWidget; +import cn.classfun.droidvm.ui.widgets.tools.CpuCorePickerDialog; public final class MainSettingsFragment extends MainBaseFragment { private static final long DAEMON_REFRESH_INTERVAL_MS = 1000L; private static final String PREFS_NAME = "droidvm_prefs"; public static final String KEY_VM_AUTO_CONSOLE = "vm_auto_console"; public static final String KEY_VM_CLEAR_LOGS_BEFORE_START = "vm_clear_logs_before_start"; - public static final String KEY_VM_KEEP_COMPRESS_ON_OPTIMIZE = "vm_keep_compress_on_optimize"; + public static final String KEY_VM_OPTIMIZE_COMPRESSION = "vm_optimize_compression"; + /** Sentinel for {@link #KEY_VM_OPTIMIZE_COMPRESSION}: prompt on every optimize. */ + public static final String OPTIMIZE_COMPRESSION_ASK = "ask"; public static final String KEY_QEMU_IMG_CPU_AFFINITY = "qemu_img_cpu_affinity"; public static final String KEY_OPTIMIZE_SDCARD = "optimize_sdcard"; public static final String KEY_AUTO_CHECK_UPDATE = "auto_check_update"; @@ -85,8 +98,9 @@ public final class MainSettingsFragment extends MainBaseFragment { private TextRowWidget itemDaemonRestart; private SwitchRowWidget itemVMAutoConsole; private SwitchRowWidget itemVMClearLogsBeforeStart; - private SwitchRowWidget itemVMKeepCompressOnOptimize; + private TextRowWidget itemVMOptimizeCompression; private SwitchRowWidget itemOptimizeSdcard; + private TextRowWidget itemUsbAcmPorts; private TextRowWidget itemCpuAffinity; private TextRowWidget itemLicense; private SwitchRowWidget itemAutoCheckUpdate; @@ -94,6 +108,7 @@ public final class MainSettingsFragment extends MainBaseFragment { private TextRowWidget itemPrivacy; private TextRowWidget itemApiManager; private TextRowWidget itemHugepageReserve; + private TextRowWidget itemKernelModules; private TextRowWidget itemExportConfig; private TextRowWidget itemImportConfig; private DaemonHelper daemon; @@ -142,8 +157,9 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat itemDaemonRestart = view.findViewById(R.id.item_daemon_restart); itemVMAutoConsole = view.findViewById(R.id.item_vm_auto_console); itemVMClearLogsBeforeStart = view.findViewById(R.id.item_vm_clear_logs_before_start); - itemVMKeepCompressOnOptimize = view.findViewById(R.id.item_vm_keep_compress_on_optimize); + itemVMOptimizeCompression = view.findViewById(R.id.item_vm_optimize_compression); itemOptimizeSdcard = view.findViewById(R.id.item_optimize_sdcard); + itemUsbAcmPorts = view.findViewById(R.id.item_usb_acm_ports); itemCpuAffinity = view.findViewById(R.id.item_cpu_affinity); itemLicense = view.findViewById(R.id.item_license); itemAutoCheckUpdate = view.findViewById(R.id.item_auto_check_update); @@ -151,6 +167,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat itemPrivacy = view.findViewById(R.id.item_privacy); itemApiManager = view.findViewById(R.id.item_api_manager); itemHugepageReserve = view.findViewById(R.id.item_hugepage_reserve); + itemKernelModules = view.findViewById(R.id.item_kernel_modules); itemExportConfig = view.findViewById(R.id.item_export_config); itemImportConfig = view.findViewById(R.id.item_import_config); exportConfigLauncher = registerForActivityResult( @@ -175,15 +192,19 @@ private void initSettings() { bindOnClick(itemDaemonRestart, daemon::asyncRestartDaemon); bindOnChecked(itemVMAutoConsole, KEY_VM_AUTO_CONSOLE, false); bindOnChecked(itemVMClearLogsBeforeStart, KEY_VM_CLEAR_LOGS_BEFORE_START, false); - bindOnChecked(itemVMKeepCompressOnOptimize, KEY_VM_KEEP_COMPRESS_ON_OPTIMIZE, false); + bindOnClick(itemVMOptimizeCompression, this::showOptimizeCompressionDialog); + refreshOptimizeCompressionSummary(); bindOnClick(itemCpuAffinity, this::showCpuAffinityDialog); refreshCpuAffinitySummary(); bindOnChecked(itemOptimizeSdcard, KEY_OPTIMIZE_SDCARD, true); + bindOnClick(itemUsbAcmPorts, this::showUsbAcmPortsDialog); + refreshUsbAcmPortsSummary(); bindOnChecked(itemAutoCheckUpdate, KEY_AUTO_CHECK_UPDATE, true); bindOnClick(itemCheckUpdate, this::checkUpdate); bindOnClick(itemPrivacy, this::showPrivacyPolicy); bindOnClick(itemApiManager, this::showApiManager); bindOnClick(itemHugepageReserve, this::showHugePageReserve); + bindOnClick(itemKernelModules, this::showKernelModules); bindOnClick(itemExportConfig, this::exportConfig); bindOnClick(itemImportConfig, this::importConfig); itemDaemonStatus.setSubtitle(R.string.settings_daemon_checking); @@ -222,9 +243,120 @@ public static boolean isClearLogsBeforeStartEnabled(@NonNull Context context) { return prefs.getBoolean(KEY_VM_CLEAR_LOGS_BEFORE_START, false); } - public static boolean isKeepCompressOnOptimizeEnabled(@NonNull Context context) { + /** + * The compression a disk optimize should target: the wire value of a member of + * {@link DiskCompress#CROSVM_SUPPORTED}, or {@link #OPTIMIZE_COMPRESSION_ASK} to prompt + * each time (the prompt's "remember" writes this back). A stored value that isn't (or is + * no longer) crosvm-supported reads as ask. + */ + @NonNull + public static String getOptimizeCompression(@NonNull Context context) { + var prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + var value = prefs.getString(KEY_VM_OPTIMIZE_COMPRESSION, OPTIMIZE_COMPRESSION_ASK); + var compress = DiskCompress.fromValue(value); + if (compress != null && compress.isCrosvmSupported()) return value; + return OPTIMIZE_COMPRESSION_ASK; + } + + public static void setOptimizeCompression(@NonNull Context context, @NonNull String value) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit().putString(KEY_VM_OPTIMIZE_COMPRESSION, value).apply(); + } + + @StringRes + private static int optimizeCompressionLabel(@NonNull String value) { + var compress = DiskCompress.fromValue(value); + if (compress != null) return OptimizeCompression.labelOf(compress); + return R.string.settings_optimize_compression_ask; + } + + private void refreshOptimizeCompressionSummary() { + itemVMOptimizeCompression.setSubtitle( + optimizeCompressionLabel(getOptimizeCompression(requireContext()))); + } + + /** Whether the USB ACM feature is switched on; VMs with an ACM port refuse to boot + * without it, so the serial editor uses this to warn at configuration time. */ + public static boolean isUsbAcmEnabled(@NonNull Context context) { var prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - return prefs.getBoolean(KEY_VM_KEEP_COMPRESS_ON_OPTIMIZE, false); + return prefs.getBoolean(UsbAcmPool.KEY_USB_ACM_ENABLE, UsbAcmPool.DEFAULT_ENABLE); + } + + /** + * USB ACM pool size the daemon pre-binds; the same clamp the daemon applies, so the + * serial-port slot picker never offers a slot the pool will refuse. + */ + public static int getUsbAcmPorts(@NonNull Context context) { + var prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + var n = prefs.getInt(UsbAcmPool.KEY_USB_ACM_PORTS, UsbAcmPool.DEFAULT_PORTS); + return Math.max(1, Math.min(UsbAcmPool.MAX_PORTS, n)); + } + + private void refreshUsbAcmPortsSummary() { + itemUsbAcmPorts.setSubtitle(isUsbAcmEnabled(requireContext()) + ? getString(R.string.settings_usb_acm_ports_summary, getUsbAcmPorts(requireContext())) + : getString(R.string.settings_usb_acm_disabled)); + } + + // One dialog for the whole feature: the enable toggle on top (the daemon builds or tears + // the pool down as soon as the config lands), pool sizes below. The re-enumeration note + // sits under the toggle permanently instead of nagging through a second dialog. + private void showUsbAcmPortsDialog() { + var context = requireContext(); + var prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + var view = getLayoutInflater().inflate(R.layout.dialog_usb_acm_ports, null); + MaterialSwitch enable = view.findViewById(R.id.switch_acm_enable); + RadioGroup group = view.findViewById(R.id.group_acm_ports); + for (int i = 1; i <= UsbAcmPool.MAX_PORTS; i++) { + var radio = new MaterialRadioButton(context); + radio.setId(i); + radio.setText(getString(R.string.settings_usb_acm_ports_summary, i)); + radio.setMinHeight(48); + group.addView(radio); + } + enable.setChecked(isUsbAcmEnabled(context)); + group.check(getUsbAcmPorts(context)); + for (int i = 0; i < group.getChildCount(); i++) + group.getChildAt(i).setEnabled(enable.isChecked()); + enable.setOnCheckedChangeListener((btn, checked) -> { + prefs.edit().putBoolean(UsbAcmPool.KEY_USB_ACM_ENABLE, checked).apply(); + VMEventHandler.sendAppConfig(requireActivity()); + for (int i = 0; i < group.getChildCount(); i++) + group.getChildAt(i).setEnabled(checked); + refreshUsbAcmPortsSummary(); + }); + group.setOnCheckedChangeListener((g, checkedId) -> { + if (checkedId <= 0) return; + prefs.edit().putInt(UsbAcmPool.KEY_USB_ACM_PORTS, checkedId).apply(); + VMEventHandler.sendAppConfig(requireActivity()); + refreshUsbAcmPortsSummary(); + }); + new MaterialAlertDialogBuilder(context) + .setTitle(R.string.settings_usb_acm_ports_title) + .setView(view) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + // Ask + every crosvm-supported compression; the list grows as CROSVM_SUPPORTED does. + private void showOptimizeCompressionDialog() { + var values = new ArrayList(); + values.add(OPTIMIZE_COMPRESSION_ASK); + for (var compress : DiskCompress.CROSVM_SUPPORTED) values.add(compress.value()); + var labels = new String[values.size()]; + for (int i = 0; i < values.size(); i++) + labels[i] = getString(optimizeCompressionLabel(values.get(i))); + var current = getOptimizeCompression(requireContext()); + int checked = Math.max(0, values.indexOf(current)); + new MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.settings_optimize_compression_title) + .setSingleChoiceItems(labels, checked, (d, which) -> { + setOptimizeCompression(requireContext(), values.get(which)); + refreshOptimizeCompressionSummary(); + d.dismiss(); + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); } public static boolean isAutoCheckUpdateEnabled(@NonNull Context context) { @@ -314,78 +446,16 @@ private void refreshCpuAffinitySummary() { private void showCpuAffinityDialog() { var ctx = requireContext(); - var cores = CpuUtils.getCores(); - int tiers = CpuUtils.tierCount(cores); - var labels = new String[cores.size()]; - for (int i = 0; i < cores.size(); i++) - labels[i] = cpuCoreLabel(cores.get(i), tiers); - // Saved selection, or the "filter out little cores" default when unset. var savedCsv = getQemuImgCpuAffinity(ctx); - var selectedIdx = parseCsvToSet( - savedCsv.isEmpty() ? CpuUtils.defaultBigCoresCsv() : savedCsv); - var checked = new boolean[cores.size()]; - for (int i = 0; i < cores.size(); i++) - checked[i] = selectedIdx.contains(cores.get(i).index); - - var dialog = new MaterialAlertDialogBuilder(ctx) - .setTitle(R.string.settings_cpu_affinity_title) - .setMultiChoiceItems(labels, checked, (d, which, isChecked) -> - checked[which] = isChecked) - .setNeutralButton(R.string.settings_cpu_affinity_big_only, null) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(android.R.string.ok, (d, w) -> { - var sb = new StringBuilder(); - for (int i = 0; i < cores.size(); i++) { - if (!checked[i]) continue; - if (sb.length() > 0) sb.append(','); - sb.append(cores.get(i).index); - } + CpuCorePickerDialog.show( + ctx, R.string.settings_cpu_affinity_title, + savedCsv.isEmpty() ? CpuUtils.defaultBigCoresCsv() : savedCsv, + picked -> { ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit().putString(KEY_QEMU_IMG_CPU_AFFINITY, sb.toString()).apply(); + .edit().putString(KEY_QEMU_IMG_CPU_AFFINITY, picked).apply(); refreshCpuAffinitySummary(); - }) - .create(); - dialog.show(); - // Re-check only the big cores without dismissing the dialog. - dialog.getButton(android.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { - var bigIdx = parseCsvToSet(CpuUtils.defaultBigCoresCsv()); - var list = dialog.getListView(); - for (int i = 0; i < cores.size(); i++) { - checked[i] = bigIdx.contains(cores.get(i).index); - list.setItemChecked(i, checked[i]); - } - }); - } - - @NonNull - private String cpuCoreLabel(@NonNull CpuUtils.CpuCore core, int tiers) { - var freq = CpuUtils.formatFreq(core.maxFreqKHz); - int tierRes; - if (tiers <= 1) tierRes = 0; - else if (core.tier == 0) tierRes = R.string.settings_cpu_affinity_tier_little; - else if (tiers >= 3 && core.tier == tiers - 1) - tierRes = R.string.settings_cpu_affinity_tier_prime; - else tierRes = R.string.settings_cpu_affinity_tier_big; - var sb = new StringBuilder(fmt("CPU%d", core.index)); - if (!freq.isEmpty()) sb.append(" ").append(freq); - if (tierRes != 0) sb.append(" ").append(getString(tierRes)); - return sb.toString(); - } - - @NonNull - private static Set parseCsvToSet(@NonNull String csv) { - var set = new HashSet(); - if (csv.isEmpty()) return set; - for (var part : csv.split(",")) { - part = part.trim(); - if (part.isEmpty()) continue; - try { - set.add(Integer.parseInt(part)); - } catch (NumberFormatException ignored) { - } - } - return set; + }); } private void showLicenseDialog() { @@ -453,6 +523,10 @@ private void showHugePageReserve() { startActivity(new Intent(requireContext(), HugePageActivity.class)); } + private void showKernelModules() { + KernelModuleDialog.show(getChildFragmentManager()); + } + private void exportConfig() { var sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()); var timestamp = sdf.format(new Date()); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/SoftwareLicenseDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/SoftwareLicenseDialog.java index 3ef82c42..c6442c8b 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/settings/SoftwareLicenseDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/settings/SoftwareLicenseDialog.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.ui.main.settings; import static android.content.Intent.ACTION_VIEW; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/vm/MainVMFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/vm/MainVMFragment.java index 0d79233c..f4da8344 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/vm/MainVMFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/vm/MainVMFragment.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.ui.main.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -14,24 +17,25 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; import cn.classfun.droidvm.DroidVMApp; import cn.classfun.droidvm.R; -import cn.classfun.droidvm.lib.daemon.DaemonConnection; import cn.classfun.droidvm.lib.daemon.ForegroundCallback; import cn.classfun.droidvm.lib.store.vm.VMConfig; import cn.classfun.droidvm.lib.store.vm.VMState; import cn.classfun.droidvm.lib.store.vm.VMStore; import cn.classfun.droidvm.ui.main.base.stateful.MainStatefulFragment; import cn.classfun.droidvm.ui.vm.VMActions; +import cn.classfun.droidvm.ui.vm.VMCreateMenu; +import cn.classfun.droidvm.ui.vm.VMDeletion; import cn.classfun.droidvm.ui.vm.console.VMConsoleRouter; import cn.classfun.droidvm.ui.vm.edit.VMEditActivity; import cn.classfun.droidvm.ui.vm.info.VMInfoActivity; import cn.classfun.droidvm.ui.vm.pkg.exports.VMPkgExportActivity; +import cn.classfun.droidvm.ui.vm.pkg.imports.VMPkgImportActivity; public final class MainVMFragment extends MainStatefulFragment @@ -73,7 +77,7 @@ public int getTitleResId() { @Override public void onFabClick(@NonNull View v) { - startActivity(new Intent(requireContext(), VMEditActivity.class)); + VMCreateMenu.show(requireContext()); } @NonNull @@ -152,23 +156,16 @@ protected void onActionClicked(VMConfig config, VMState currentState) { } } - private void deleteVM(@NonNull VMConfig config) { + private void deleteVM(@NonNull VMConfig config, boolean deleteDisks) { var ctx = requireContext(); adapter.items.removeById(config.getId()); - adapter.items.save(ctx); + boolean saved = adapter.items.save(ctx); refreshView(); - DaemonConnection.getInstance().buildRequest("vm_delete") - .put("vm_id", config.getId().toString()) - .invoke(); + VMDeletion.releaseDaemonAndMaybeDeleteDisks(ctx, config, deleteDisks, saved); } private void confirmDeleteVM(@NonNull VMConfig config) { - var ctx = requireContext(); - new MaterialAlertDialogBuilder(ctx) - .setTitle(config.getName()) - .setMessage(R.string.vm_delete_confirm) - .setPositiveButton(R.string.vm_delete, (d, w) -> deleteVM(config)) - .setNegativeButton(android.R.string.cancel, null) - .show(); + VMDeletion.confirm(requireContext(), config, + deleteDisks -> deleteVM(config, deleteDisks)); } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/main/vm/VMAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/main/vm/VMAdapter.java index 29a66d32..06fcb5b8 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/main/vm/VMAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/main/vm/VMAdapter.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.ui.main.vm; import static android.view.View.VISIBLE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/markdown/MarkdownRender.kt b/app/src/main/java/cn/classfun/droidvm/ui/markdown/MarkdownRender.kt new file mode 100644 index 00000000..1ac03684 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/markdown/MarkdownRender.kt @@ -0,0 +1,114 @@ +// 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.ui.markdown + +import android.content.Context +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.text.font.FontWeight +import com.google.android.material.color.MaterialColors +import com.mikepenz.markdown.m3.Markdown +import com.mikepenz.markdown.m3.markdownTypography +import com.mikepenz.markdown.model.MarkdownTypography + +/** + * The one place Markdown is turned into views. Everything else in the app is Java and Android + * Views; this is Compose because the renderer is, and it is kept to a single entry point so that + * the editor's preview and the read-only cards cannot drift apart -- they are the same call. + * + * The palette is lifted off the hosting Android theme rather than Compose's defaults, so a card + * of rendered notes sits on the same surface colour as the card next to it instead of arriving + * in Compose purple. + * + * One rule for hosts: the [ComposeView] must be measured with a bounded width. Markdown puts + * tables and code blocks in horizontally scrollable containers, and Compose throws rather than + * lay one of those out against an infinite width -- which is what a weighted LinearLayout child + * is measured with on its first pass. + */ +object MarkdownRender { + /** Renders [markdown] into [view], replacing whatever it held. */ + @JvmStatic + fun bind(view: ComposeView, markdown: String) { + view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + view.setContent { + MaterialTheme(colorScheme = schemeOf(view.context)) { + Markdown( + content = markdown, + typography = phoneTypography(), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + + /** + * A heading ladder for a phone card. The library's defaults start at displayLarge, which is + * 57sp: right for a document rendered on its own page, absurd in a card three of which fit on + * one screen -- an h1 would take a line and a half on its own. This tops out at headlineSmall + * and steps down from there, so a heading still reads as a heading next to 14sp body text. + */ + @Composable + private fun phoneTypography(): MarkdownTypography { + val type = MaterialTheme.typography + val bold = FontWeight.SemiBold + return markdownTypography( + h1 = type.headlineSmall.copy(fontWeight = bold), + h2 = type.titleLarge.copy(fontWeight = bold), + h3 = type.titleMedium.copy(fontWeight = bold), + h4 = type.titleSmall.copy(fontWeight = bold), + h5 = type.bodyLarge.copy(fontWeight = bold), + h6 = type.bodyMedium.copy(fontWeight = bold), + text = type.bodyMedium, + paragraph = type.bodyMedium, + ordered = type.bodyMedium, + bullet = type.bodyMedium, + list = type.bodyMedium, + table = type.bodySmall, + ) + } + + /** The host theme's Material colours, as the scheme Compose draws with. */ + private fun schemeOf(context: Context): ColorScheme { + val surface = color(context, com.google.android.material.R.attr.colorSurface, 0xFF121212) + val onSurface = color(context, com.google.android.material.R.attr.colorOnSurface, 0xFFE6E6E6) + val primary = color(context, androidx.appcompat.R.attr.colorPrimary, 0xFF7DA0FA) + val onSurfaceVariant = + color(context, com.google.android.material.R.attr.colorOnSurfaceVariant, 0xFFB0B0B0) + val outline = color(context, com.google.android.material.R.attr.colorOutline, 0xFF6F6F6F) + // surfaceVariant is what code spans and blocks sit on: a shade off the card, either way. + val surfaceVariant = surface.shiftedTowards(onSurface, 0.08f) + val base = if (surface.luminance() < 0.5f) darkColorScheme() else lightColorScheme() + return base.copy( + primary = primary, + onPrimary = surface, + background = surface, + onBackground = onSurface, + surface = surface, + onSurface = onSurface, + surfaceVariant = surfaceVariant, + onSurfaceVariant = onSurfaceVariant, + outline = outline, + ) + } + + private fun color(context: Context, attr: Int, fallback: Long): Color = + Color(MaterialColors.getColor(context, attr, Color(fallback).toArgb())) + + /** [fraction] of the way from this colour to [other]; both are opaque. */ + private fun Color.shiftedTowards(other: Color, fraction: Float): Color = Color( + red = red + (other.red - red) * fraction, + green = green + (other.green - green) * fraction, + blue = blue + (other.blue - blue) * fraction, + ) +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/network/NetworkActions.java b/app/src/main/java/cn/classfun/droidvm/ui/network/NetworkActions.java index 4e8a4383..391d0ad2 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/network/NetworkActions.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/network/NetworkActions.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.ui.network; import static android.widget.Toast.LENGTH_LONG; @@ -76,6 +79,38 @@ public static void createAndStart( .invoke(); } + /** + * Makes the daemon's picture of a network match the store's: it creates the network there + * when the daemon does not know it yet, and modifies it when it does. Registering is not + * starting -- the instance lands STOPPED either way. + * + *

    Every network the app writes to networks.json has to go through here, because the + * daemon only re-reads that file when it starts. A network it has never been told about is + * one it cannot resolve: a VM whose NIC names it refuses to start ("Network ... not found"), + * and an export of that VM silently packs no network at all. + * + *

    Best effort and silent: a daemon that is not running yet will read the network out of + * networks.json when it starts, which is the case the setup wizard is normally in. + */ + public static void syncToDaemon(@NonNull NetworkConfig config) { + var conn = DaemonConnection.getInstance(); + DaemonConnection.OnUnsuccessful f = r -> + Log.w(TAG, fmt("Daemon refused the network sync: %s", r.optString("message", ""))); + DaemonConnection.OnError err = e -> Log.w(TAG, "Daemon network sync failed", e); + conn.buildRequest("network_exists") + .put("network_id", config.getId()) + .onResponse(resp -> conn + .buildRequest(resp.optBoolean("exists", false) + ? "network_modify" : "network_create") + .put("config", config) + .onUnsuccessful(f) + .onError(err) + .invoke()) + .onUnsuccessful(f) + .onError(err) + .invoke(); + } + public static void deleteNetwork( @NonNull Context context, @NonNull Handler mainHandler, @@ -160,7 +195,10 @@ private static void collectRunningVMsUsingNetwork( ) { try { JsonUtils.forEachArray(resp, "data", (JSONObject vm) -> { - if (vm.optString("state", "stopped").equals("stopped")) return; + // vm_list reports the enum name ("STOPPED"); vm_status lower-cases it. Compare + // either way -- a case-sensitive match here read every stopped VM as running and + // so refused to delete any network a VM had ever been attached to. + if (vm.optString("state", "stopped").equalsIgnoreCase("stopped")) return; var vmId = vm.optString("id", ""); var vmCfg = vmStore.findById(vmId); if (vmCfg == null) return; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/network/NetworkPresets.java b/app/src/main/java/cn/classfun/droidvm/ui/network/NetworkPresets.java new file mode 100644 index 00000000..944c7af6 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/network/NetworkPresets.java @@ -0,0 +1,218 @@ +// 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.ui.network; + +import static cn.classfun.droidvm.lib.utils.NetUtils.generateRandomMac; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.List; +import java.util.Random; +import java.util.UUID; + +import cn.classfun.droidvm.daemon.network.backend.UplinkResolver; +import cn.classfun.droidvm.lib.network.IPv4Network; +import cn.classfun.droidvm.lib.network.IPv6Network; +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.network.BridgeType; +import cn.classfun.droidvm.lib.store.network.Ipv6Source; +import cn.classfun.droidvm.lib.store.network.NetworkConfig; +import cn.classfun.droidvm.lib.store.network.NetworkConfigValidator; +import cn.classfun.droidvm.lib.store.network.NetworkConflicts; +import cn.classfun.droidvm.lib.store.network.NetworkStore; +import cn.classfun.droidvm.lib.store.network.UplinkMode; +import cn.classfun.droidvm.lib.store.network.VlanConfig; + +/** + * Ready-made network configs, and the address picking they share with the network editor. + * + *

    The editor builds its blank form from the same helpers, so a network created here in one + * tap and one typed out by hand pick their subnets from the same pool and avoid the same + * conflicts. Everything is static: none of it needs a live screen. + */ +public final class NetworkPresets { + /** bridge + "v"/"." + 2-char VLAN code must fit IFNAMSIZ (15 usable). */ + public static final int MAX_BRIDGE_NAME_LEN = NetworkConfigValidator.MAX_BRIDGE_NAME_LEN; + private static final Random RANDOM = new Random(); + + private NetworkPresets() { + } + + /** + * A Wi-Fi pseudo-bridge: the VM sits on the phone's own Wi-Fi segment and takes its address + * from the upstream router, so there is nothing for us to address, NAT or serve DHCP on. + * Wi-Fi in station mode cannot be enslaved into a Linux bridge, hence pseudo-bridging. + * + * @param name used as both the network name and the bridge interface name + */ + @NonNull + public static NetworkConfig wifiPseudoBridge(@NonNull String name) { + var config = newConfig(name, BridgeType.LINUX, UplinkMode.L2); + config.l2().set("uplink", UplinkResolver.ID_WIFI); + config.l2().set("pseudo_bridge", true); + return config; + } + + /** + * A routed network with one untagged VLAN: NAT to whatever uplink the host has, and DHCP for + * the VMs on it. Which address families that covers depends on the bridge type -- see + * {@link #newVlan}. + * + * @param name used as both the network name and the bridge interface name + * @param pair primary IPv4 and IPv6 CIDRs from {@link #pickFreeCidrPair}, or null to leave + * the VLAN unaddressed + */ + @NonNull + public static NetworkConfig routedNat( + @NonNull BridgeType type, @NonNull String name, @Nullable String[] pair + ) { + var config = newConfig(name, type, UplinkMode.L3); + config.l3().set("mac_address", generateRandomMac()); + var vlans = DataItem.newArray(); + vlans.append(newVlan(0, type, pair).item); + config.l3().set("vlans", vlans); + return config; + } + + /** + * The shell both presets fill in. {@code auto_up} is on because a preset exists to spare the + * user the setup: the daemon reads networks.json on start and brings it up from there, which + * is also the only way a network created before the daemon runs ever starts. + */ + @NonNull + private static NetworkConfig newConfig( + @NonNull String name, @NonNull BridgeType type, @NonNull UplinkMode mode + ) { + var config = new NetworkConfig(); + config.setName(name); + config.setBridgeName(name); + config.item.set("auto_up", true); + config.item.set("stp", false); + config.setBridgeType(type); + config.setUplinkMode(mode); + return config; + } + + /** A new VLAN entry with the given paired networks applied (unaddressed when null). */ + @NonNull + public static VlanConfig newVlan( + int vlanId, @NonNull BridgeType type, @Nullable String[] pair + ) { + var vlan = VlanConfig.createDefault(vlanId); + if (pair != null) { + vlan.ipv4().set("cidr", pair[0]); + vlan.ipv6().set("cidr", pair[1]); + } + var ipv6 = vlan.ipv6(); + if (type == BridgeType.GVISOR) { + // gVisor has IPv6 SNAT, so the ULA prefix is routable: default on + ipv6.set("snat", true); + } else { + // a Linux bridge has no IPv6 NAT and Android rarely holds a + // routed prefix, so serving the ULA via DHCPv6/SLAAC hands VMs + // addresses with no connectivity: default to a static ULA CIDR + // with serving off, and pre-fill the Wi-Fi PD uplink for when the + // user switches the source to DHCP-PD + ipv6.set("snat", false); + ipv6.set("source", Ipv6Source.STATIC.key()); + var pd = DataItem.newObject(); + pd.set("uplink", UplinkResolver.ID_WIFI); + ipv6.set("pd", pd); + ipv6.get("dhcp").set("enabled", false); + ipv6.get("slaac").set("enabled", false); + } + return vlan; + } + + /** + * Picks N in 50-250 so that 192.168.N.1/24 and fd00:N::1/64 are both free of overlaps with + * everything in {@code used4}/{@code used6}. Returns null when no N fits. + */ + @Nullable + public static String[] pickFreeCidrPair( + @NonNull List used4, @NonNull List used6 + ) { + for (int attempt = 0; attempt < 400; attempt++) { + int n = 50 + RANDOM.nextInt(201); // 50-250 + IPv4Network cand4; + IPv6Network cand6; + try { + cand4 = IPv4Network.parse(fmt("192.168.%d.1/24", n)); + cand6 = IPv6Network.parse(fmt("fd00:%d::1/64", n)); + } catch (Exception e) { + continue; + } + boolean conflicts = false; + for (var ex : used4) + if (cand4.overlaps(ex)) { + conflicts = true; + break; + } + if (!conflicts) for (var ex : used6) + if (cand6.overlaps(ex)) { + conflicts = true; + break; + } + if (!conflicts) return new String[]{cand4.toString(), cand6.toString()}; + } + return null; + } + + /** Every subnet in the store bar {@code exclude}, so a suggestion can avoid them all. */ + public static void collectStoreNetworks( + @NonNull NetworkStore store, @Nullable UUID exclude, + @NonNull List out4, @NonNull List out6 + ) { + collectStoreNetworks(store, exclude, null, out4, out6); + } + + /** + * The same, narrowed to one bridge type. Only networks of the same type can actually collide + * (see {@link NetworkConflicts}), so a suggestion for a gVisor network has no reason to walk + * around what the Linux bridges hold -- and every such detour costs it a subnet from a pool + * of 201. + * + * @param type the type being addressed, or null to avoid every network whatever its type + */ + public static void collectStoreNetworks( + @NonNull NetworkStore store, @Nullable UUID exclude, @Nullable BridgeType type, + @NonNull List out4, @NonNull List out6 + ) { + store.forEach((id, cfg) -> { + if (exclude != null && exclude.equals(id)) return; + if (type != null && cfg.getBridgeType() != type) return; + NetworkConflicts.collectSubnets(cfg.getVlans(), out4, out6); + }); + } + + /** + * {@code base}, or the first free variant of it, as a name no other network uses for either + * its display name or its bridge. Falls back to {@code base} when nothing fits, leaving the + * duplicate for the caller's validation to reject. + */ + @NonNull + public static String uniqueName(@NonNull NetworkStore store, @NonNull String base) { + if (isNameFree(store, base)) return base; + var prefix = base.replaceAll("\\d+$", ""); + var digits = base.substring(prefix.length()); + long n; + try { + n = digits.isEmpty() ? 0 : Long.parseLong(digits); + } catch (NumberFormatException e) { + n = 0; + } + for (int i = 0; i < 1000; i++) { + var candidate = fmt("%s%d", prefix, ++n); + if (candidate.length() <= MAX_BRIDGE_NAME_LEN && isNameFree(store, candidate)) + return candidate; + } + return base; + } + + private static boolean isNameFree(@NonNull NetworkStore store, @NonNull String name) { + return store.isNameUnique(name) && store.isBridgeNameUnique(name, null); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/network/edit/NetworkEditActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/network/edit/NetworkEditActivity.java index 4ba17b64..cd1b385e 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/network/edit/NetworkEditActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/network/edit/NetworkEditActivity.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.ui.network.edit; import static android.view.View.GONE; @@ -29,7 +32,6 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; -import java.util.Random; import java.util.UUID; import cn.classfun.droidvm.R; @@ -39,27 +41,26 @@ import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.daemon.network.backend.UplinkResolver; import cn.classfun.droidvm.lib.store.network.BridgeType; -import cn.classfun.droidvm.lib.store.network.Ipv6Source; import cn.classfun.droidvm.lib.store.network.NetworkConfig; import cn.classfun.droidvm.lib.store.network.NetworkConfigValidator; +import cn.classfun.droidvm.lib.store.network.NetworkConflicts; import cn.classfun.droidvm.lib.store.network.NetworkStore; import cn.classfun.droidvm.lib.store.network.UplinkMode; import cn.classfun.droidvm.lib.store.network.VlanConfig; import cn.classfun.droidvm.lib.ui.BackAskHelper; import cn.classfun.droidvm.lib.ui.IconItemAdapter; +import cn.classfun.droidvm.ui.network.NetworkActions; +import cn.classfun.droidvm.ui.network.NetworkPresets; import cn.classfun.droidvm.ui.widgets.row.DropdownRowWidget; import cn.classfun.droidvm.ui.widgets.row.SwitchRowWidget; import cn.classfun.droidvm.ui.widgets.row.TextInputRowWidget; public final class NetworkEditActivity extends AppCompatActivity { public static final String EXTRA_NETWORK_ID = "network_id"; - /** bridge + "v"/"." + 2-char VLAN code must fit IFNAMSIZ (15 usable). */ - private static final int MAX_BRIDGE_NAME_LEN = 12; /** Interface-name charset: ASCII letters, digits, hyphen, underscore. */ private static final InputFilter BRIDGE_NAME_CHARSET = (src, start, end, dst, ds, de) -> src.subSequence(start, end).toString().matches("[A-Za-z0-9_-]*") ? null : ""; private final Handler mainHandler = new Handler(Looper.getMainLooper()); - private final Random random = new Random(); private final List vlans = new ArrayList<>(); private final List binders = new ArrayList<>(); // parallel uplink picker entries: display label, stored value (logical id @@ -140,7 +141,7 @@ private void initialize() { btnAddVlan.setOnClickListener(v -> onAddVlan()); inputMac.setEndIconOnClickListener(v -> inputMac.setText(generateRandomMac())); inputBridge.setFilters( - new InputFilter.LengthFilter(MAX_BRIDGE_NAME_LEN), + new InputFilter.LengthFilter(NetworkPresets.MAX_BRIDGE_NAME_LEN), BRIDGE_NAME_CHARSET ); fab.setOnClickListener(v -> onSaveClicked()); @@ -334,97 +335,21 @@ private void generateDefaults() { /** A new VLAN entry with paired random networks (empty when exhausted). */ @NonNull private VlanConfig newVlan(int vlanId) { - var vlan = VlanConfig.createDefault(vlanId); - var pair = generatePairedCidrs(); - if (pair != null) { - vlan.ipv4().set("cidr", pair[0]); - vlan.ipv6().set("cidr", pair[1]); - } - var ipv6 = vlan.ipv6(); - if (bridgeType == BridgeType.GVISOR) { - // gVisor has IPv6 SNAT, so the ULA prefix is routable: default on - ipv6.set("snat", true); - } else { - // a Linux bridge has no IPv6 NAT and Android rarely holds a - // routed prefix, so serving the ULA via DHCPv6/SLAAC hands VMs - // addresses with no connectivity: default to a static ULA CIDR - // with serving off, and pre-fill the Wi-Fi PD uplink for when the - // user switches the source to DHCP-PD - ipv6.set("snat", false); - ipv6.set("source", Ipv6Source.STATIC.key()); - var pd = DataItem.newObject(); - pd.set("uplink", UplinkResolver.ID_WIFI); - ipv6.set("pd", pd); - ipv6.get("dhcp").set("enabled", false); - ipv6.get("slaac").set("enabled", false); - } - return vlan; + return NetworkPresets.newVlan(vlanId, bridgeType, generatePairedCidrs()); } /** - * Picks N in 50-250 so that 192.168.N.1/24 and fd00:N::1/64 are both - * free of overlaps with every other network and this network's other - * VLANs. Returns null when no N fits. + * A free subnet pair, avoiding every other network and this network's own + * VLAN cards. Null when nothing in the pool fits. */ @Nullable private String[] generatePairedCidrs() { var used4 = new ArrayList(); var used6 = new ArrayList(); - collectUsedNetworks(used4, used6); - for (int attempt = 0; attempt < 400; attempt++) { - int n = 50 + random.nextInt(201); // 50-250 - IPv4Network cand4; - IPv6Network cand6; - try { - cand4 = IPv4Network.parse(fmt("192.168.%d.1/24", n)); - cand6 = IPv6Network.parse(fmt("fd00:%d::1/64", n)); - } catch (Exception e) { - continue; - } - boolean conflicts = false; - for (var ex : used4) - if (cand4.overlaps(ex)) { - conflicts = true; - break; - } - if (!conflicts) for (var ex : used6) - if (cand6.overlaps(ex)) { - conflicts = true; - break; - } - if (!conflicts) return new String[]{cand4.toString(), cand6.toString()}; - } - return null; - } - - /** Subnets in use by other networks and by this network's VLAN cards. */ - private void collectUsedNetworks( - @NonNull List out4, @NonNull List out6 - ) { storeAllBinders(); - var sources = new ArrayList<>(vlans); - store.forEach((id, cfg) -> { - if (id.equals(editNetworkId)) return; - sources.addAll(cfg.getVlans()); - }); - for (var vlan : sources) { - var net4 = vlan.getIpv4Network(); - if (net4 != null) out4.add(net4); - for (var cidr : vlan.getIpv4Secondary()) { - try { - out4.add(IPv4Network.parse(cidr)); - } catch (Exception ignored) { - } - } - var net6 = vlan.getIpv6Network(); - if (net6 != null) out6.add(net6); - for (var cidr : vlan.getIpv6Secondary()) { - try { - out6.add(IPv6Network.parse(cidr)); - } catch (Exception ignored) { - } - } - } + NetworkConflicts.collectSubnets(vlans, used4, used6); + NetworkPresets.collectStoreNetworks(store, editNetworkId, bridgeType, used4, used6); + return NetworkPresets.pickFreeCidrPair(used4, used6); } private void loadExistingConfig() { @@ -531,7 +456,7 @@ private void onSaveClicked() { return; } if (!bridgeName.matches("[a-zA-Z][a-zA-Z0-9_-]*") - || bridgeName.length() > MAX_BRIDGE_NAME_LEN) { + || bridgeName.length() > NetworkPresets.MAX_BRIDGE_NAME_LEN) { inputBridge.setError(getString(R.string.network_edit_error_bridge_invalid)); return; } @@ -564,9 +489,9 @@ private void onSaveClicked() { Toast.makeText(this, e.getMessage(), LENGTH_LONG).show(); return; } - var overlap = checkOverlaps(config); - if (overlap != null) { - Toast.makeText(this, overlap, LENGTH_LONG).show(); + var conflict = checkConflicts(config); + if (conflict != null) { + Toast.makeText(this, conflict, LENGTH_LONG).show(); return; } if (editMode) { @@ -575,7 +500,7 @@ private void onSaveClicked() { store.add(config); } store.save(this); - syncToDaemon(config); + NetworkActions.syncToDaemon(config); Toast.makeText(this, editMode ? getString(R.string.network_edit_saved, name) : getString(R.string.network_create_success, name), @@ -583,86 +508,28 @@ private void onSaveClicked() { finish(); } - /** Push the modified config to the daemon if it already knows the network. */ - private void syncToDaemon(@NonNull NetworkConfig config) { - var conn = DaemonConnection.getInstance(); - conn.buildRequest("network_exists") - .put("network_id", config.getId()) - .onResponse(resp -> { - if (!resp.optBoolean("exists", false)) return; - conn.buildRequest("network_modify") - .put("config", config) - .onUnsuccessful(r -> { - }) - .onError(e -> { - }) - .invoke(); - }) - .onUnsuccessful(r -> { - }) - .onError(e -> { - }) - .invoke(); - } - + /** + * The reason this config cannot be saved alongside the others, or null when it can. Only + * networks of the same kind are consulted -- see {@link NetworkConflicts} for why a Linux + * bridge and a gVisor network are free to hold the same prefix. + */ @Nullable - private String checkOverlaps(@NonNull NetworkConfig config) { - var myV4 = new ArrayList(); - var myV6 = new ArrayList(); - for (var vlan : config.getVlans()) { - var net4 = vlan.getIpv4Network(); - if (net4 != null) myV4.add(net4); - for (var cidr : vlan.getIpv4Secondary()) { - try { - myV4.add(IPv4Network.parse(cidr)); - } catch (Exception ignored) { - } - } - var net6 = vlan.getIpv6Network(); - if (net6 != null) myV6.add(net6); - for (var cidr : vlan.getIpv6Secondary()) { - try { - myV6.add(IPv6Network.parse(cidr)); - } catch (Exception ignored) { - } - } + private String checkConflicts(@NonNull NetworkConfig config) { + var self = NetworkConflicts.findSelfOverlap(config); + if (self != null) + return getString(R.string.network_edit_error_self_overlap, self[0], self[1]); + var conflict = NetworkConflicts.find(config, store, editNetworkId); + if (conflict == null) return null; + switch (conflict.kind) { + case IPV4: + return getString(R.string.network_edit_error_ipv4_overlap, + conflict.mine, conflict.otherName(), conflict.theirs); + case IPV6: + return getString(R.string.network_edit_error_ipv6_overlap, + conflict.mine, conflict.otherName(), conflict.theirs); + default: + return getString(R.string.network_edit_error_uplink_taken, + conflict.mine, conflict.otherName()); } - // overlaps within this network - for (int i = 0; i < myV4.size(); i++) - for (int j = i + 1; j < myV4.size(); j++) - if (myV4.get(i).overlaps(myV4.get(j))) - return getString(R.string.network_edit_error_self_overlap, - myV4.get(i).toString(), myV4.get(j).toString()); - for (int i = 0; i < myV6.size(); i++) - for (int j = i + 1; j < myV6.size(); j++) - if (myV6.get(i).overlaps(myV6.get(j))) - return getString(R.string.network_edit_error_self_overlap, - myV6.get(i).toString(), myV6.get(j).toString()); - // overlaps against other networks - var result = new String[1]; - store.forEach((id, other) -> { - if (result[0] != null || id.equals(editNetworkId)) return; - for (var vlan : other.getVlans()) { - var otherNet = vlan.getIpv4Network(); - if (otherNet != null) { - for (var mine : myV4) { - if (!mine.overlaps(otherNet)) continue; - result[0] = getString(R.string.network_edit_error_ipv4_overlap, - mine.toString(), other.getName(), otherNet); - return; - } - } - var otherNet6 = vlan.getIpv6Network(); - if (otherNet6 != null) { - for (var mine : myV6) { - if (!mine.overlaps(otherNet6)) continue; - result[0] = getString(R.string.network_edit_error_ipv6_overlap, - mine.toString(), other.getName(), otherNet6); - return; - } - } - } - }); - return result[0]; } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/network/edit/VlanCardBinder.java b/app/src/main/java/cn/classfun/droidvm/ui/network/edit/VlanCardBinder.java index 5e640ea7..3c508003 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/network/edit/VlanCardBinder.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/network/edit/VlanCardBinder.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.ui.network.edit; import static android.view.View.GONE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/network/info/NetworkInfoActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/network/info/NetworkInfoActivity.java index 3e414990..f6262f68 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/network/info/NetworkInfoActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/network/info/NetworkInfoActivity.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.ui.network.info; import static android.view.View.GONE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/network/info/NetworkToolLogActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/network/info/NetworkToolLogActivity.java index 2659a6a2..dd0b4665 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/network/info/NetworkToolLogActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/network/info/NetworkToolLogActivity.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.ui.network.info; import android.os.Bundle; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/SetupActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/SetupActivity.java index b419f57b..70488a82 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/SetupActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/SetupActivity.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.ui.setup; import static android.view.View.GONE; @@ -26,6 +29,8 @@ import cn.classfun.droidvm.ui.setup.base.BaseStepFragment; import cn.classfun.droidvm.ui.setup.step.DoneStepFragment; import cn.classfun.droidvm.ui.setup.step.ExtractStepFragment; +import cn.classfun.droidvm.ui.setup.step.KernelModuleStepFragment; +import cn.classfun.droidvm.ui.setup.step.NetworkStepFragment; import cn.classfun.droidvm.ui.setup.step.PrivacyStepFragment; import cn.classfun.droidvm.ui.setup.step.RootStepFragment; import cn.classfun.droidvm.ui.setup.step.SocStepFragment; @@ -79,6 +84,10 @@ protected void onCreate(Bundle savedInstanceState) { new StorageStepFragment(this), new PrivacyStepFragment(this), new ExtractStepFragment(this), + // After extract: the module list reads the .ko files extract just put in place. + new KernelModuleStepFragment(this), + // Before Done, so the user leaves the wizard with a network to attach a VM to. + new NetworkStepFragment(this), new DoneStepFragment(this), }; var targetStep = getIntent().getStringExtra(EXTRA_TARGET_STEP); @@ -114,24 +123,36 @@ private BaseStepFragment findStepByClassName(@NonNull String className) { return null; } + /** + * Advance to the next step that wants to be shown, or leave the wizard when none does. + * + *

    The bounds check has to come before the {@code get}, and "everything after this is + * hidden" has to end the wizard rather than fall off the end: a step can hide itself for + * reasons that hold on a whole class of devices (the kernel-module page does, on a phone + * none of the modules were written for), so a run of hidden steps at the tail is a normal + * outcome, not an impossible one. + */ public void onStepCompleted() { - if (currentStep < steps.size() - 1) { - do currentStep++; - while (steps.get(currentStep).isHiddenStep() && currentStep < steps.size()); - hideFab(); - showStep(true, true); - } else { + int next = currentStep + 1; + while (next < steps.size() && steps.get(next).isHiddenStep()) next++; + if (next >= steps.size()) { startActivity(new Intent(this, MainActivity.class)); finish(); + return; } + currentStep = next; + hideFab(); + showStep(true, true); } + /** Back to the previous shown step; a hidden one is skipped here too, not landed on. */ public void onStepBack() { - if (currentStep > 0) { - currentStep--; - hideFab(); - showStep(true, false); - } + int prev = currentStep - 1; + while (prev >= 0 && steps.get(prev).isHiddenStep()) prev--; + if (prev < 0) return; + currentStep = prev; + hideFab(); + showStep(true, false); } public void showFab(int iconRes, Runnable action) { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/base/BaseCheckStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/base/BaseCheckStepFragment.java index cbd35647..99e90757 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/base/BaseCheckStepFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/base/BaseCheckStepFragment.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.ui.setup.base; import static android.view.View.GONE; @@ -33,9 +36,25 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat runCheck(); } + /** + * onViewCreated has already run the check by the time the first onResume arrives, and every + * runCheck() here starts a worker thread. Re-running it immediately means two workers doing + * the same job: harmless for the read-only steps, but the extract step writes files, and two + * of those racing produced "extraction failed" followed by "extraction succeeded" -- the + * loser tripping over the directories the winner was creating. + * + *

    Later resumes still re-check, which is the point of doing it here at all: the user + * leaves to grant root or a permission and comes back expecting the step to notice. + */ + private boolean resumedBefore; + @Override public void onResume() { super.onResume(); + if (!resumedBefore) { + resumedBefore = true; + return; + } runCheck(); } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/base/BaseStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/base/BaseStepFragment.java index e2441466..c0f75073 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/base/BaseStepFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/base/BaseStepFragment.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.ui.setup.base; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/DoneStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/DoneStepFragment.java index 226ea9d8..699a52e6 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/DoneStepFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/DoneStepFragment.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.ui.setup.step; import static android.content.Context.MODE_PRIVATE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/ExtractStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/ExtractStepFragment.java index 23afa2ef..18a5d095 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/ExtractStepFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/ExtractStepFragment.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.ui.setup.step; import static cn.classfun.droidvm.lib.utils.AssetUtils.extractPrebuilt; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/KernelModuleStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/KernelModuleStepFragment.java new file mode 100644 index 00000000..9160eb7e --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/KernelModuleStepFragment.java @@ -0,0 +1,90 @@ +// 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.ui.setup.step; + +import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; + +import android.os.Bundle; +import android.text.Html; +import android.text.method.LinkMovementMethod; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.ui.main.settings.KernelModuleListController; +import cn.classfun.droidvm.ui.main.settings.KernelModuleMatch; +import cn.classfun.droidvm.ui.setup.SetupActivity; +import cn.classfun.droidvm.ui.setup.base.BaseStepFragment; + +/** + * Setup step reminding the user the host kernel modules must be loaded: shows the same list the + * settings' Kernel Module dialog shows ({@link KernelModuleListController}), right after the + * extract step put the .ko files in place. Loading here is optional (each card explains itself), + * so the continue FAB is always available. + * + *

    Skipped entirely on a device none of the modules were written for -- every module shipped so + * far is Qualcomm/Gunyah work, and on a MediaTek or Tensor phone this page would be an empty list + * telling the user to load it. Settings still opens the same list there; it just shows nothing. + */ +public final class KernelModuleStepFragment extends BaseStepFragment { + /** + * Whether anything applies here. Answered off the main thread when the root check lands (the + * same signal the extract step waits for), because {@link #isHiddenStep()} is asked during a + * step transition and cannot go and find out then. Defaults to showing the page: an answer + * that never arrived is not evidence that the page is useless. + */ + private volatile boolean applicable = true; + + public KernelModuleStepFragment(SetupActivity activity) { + this.activity = activity; + // Registered here, not in onAttach: a step is attached only once it is shown, and the + // question "should this step be shown at all" is settled several steps earlier. Its own + // slot key, because the event map is keyed by slot and another step already listens for + // this event -- reusing the key would silently unregister that one. + addEventListener("rootCheckDone.kmod", this::onEvent); + } + + @Override + public void onDestroy() { + removeEventListener("rootCheckDone.kmod"); + super.onDestroy(); + } + + private void onEvent(@NonNull String type) { + if (!type.equals("rootCheckDone")) return; + var ctx = activity.getApplicationContext(); + runOnPool(() -> applicable = KernelModuleMatch.anyApplicable(ctx)); + } + + @Override + public boolean isHiddenStep() { + return !applicable; + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState + ) { + return inflater.inflate(R.layout.fragment_setup_step_kmod, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + TextView desc = view.findViewById(R.id.kmod_desc); + desc.setText(Html.fromHtml(getString(R.string.setup_kmod_desc), + Html.FROM_HTML_MODE_COMPACT)); + desc.setMovementMethod(LinkMovementMethod.getInstance()); + new KernelModuleListController(requireContext(), view).refresh(); + activity.showFab(R.drawable.ic_arrow_forward, activity::onStepCompleted); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/NetworkStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/NetworkStepFragment.java new file mode 100644 index 00000000..bf8b65c6 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/NetworkStepFragment.java @@ -0,0 +1,173 @@ +// 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.ui.setup.step; + +import static android.widget.Toast.LENGTH_LONG; +import static android.widget.Toast.LENGTH_SHORT; + +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.RadioGroup; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.network.IPv4Network; +import cn.classfun.droidvm.lib.network.IPv6Network; +import cn.classfun.droidvm.lib.store.network.BridgeType; +import cn.classfun.droidvm.lib.store.network.NetworkConfig; +import cn.classfun.droidvm.lib.store.network.NetworkConfigValidator; +import cn.classfun.droidvm.lib.store.network.NetworkStore; +import cn.classfun.droidvm.ui.network.NetworkActions; +import cn.classfun.droidvm.ui.network.NetworkPresets; +import cn.classfun.droidvm.ui.setup.SetupActivity; +import cn.classfun.droidvm.ui.setup.base.BaseStepFragment; + +/** + * Setup step that creates the user's first network. + * + *

    A VM with no NIC attached comes up with no connectivity and nothing on screen says why, so + * people reach the VM list without ever visiting the Networks tab and conclude networking is + * broken. This page makes one exist before that can happen: three presets, the addresses shown up + * front, and the wizard's forward button creates the chosen one. + * + *

    The network is only written to networks.json here, never started. The daemon is launched by + * the main screen, which the wizard has not reached yet, so the preset carries {@code auto_up} + * and the daemon brings it up when it reads the file on start. + */ +public final class NetworkStepFragment extends BaseStepFragment { + private static final String TAG = "NetworkStepFragment"; + /** Preset names, doubling as bridge interface names. */ + private static final String NAME_WIFI = "br-wifi"; + private static final String NAME_LINUX = "br-net0"; + private static final String NAME_GVISOR = "br-gv0"; + + private final NetworkStore store = new NetworkStore(); + private boolean storeLoaded = false; + private boolean created = false; + private RadioGroup rgPreset; + private TextView tvInfo; + /** + * The 192.168.N.1/24 and fd00:N::1/64 pair the routed presets will use. Picked once, when the + * page is first shown, because the page prints it: re-rolling it per redraw would show the + * user addresses other than the ones they are about to get. + */ + @Nullable + private String[] cidrPair; + + public NetworkStepFragment(SetupActivity activity) { + this.activity = activity; + } + + @NonNull + private NetworkStore store() { + if (!storeLoaded) { + store.load(activity.getApplicationContext()); + storeLoaded = true; + } + return store; + } + + /** + * Shown only when the user has no network at all. Anyone who already has one either built it + * themselves or came through this page already, and neither wants a second one appended on a + * re-run of the wizard. + */ + @Override + public boolean isHiddenStep() { + return created || !store().isEmpty(); + } + + @Nullable + @Override + public View onCreateView( + @NonNull LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState + ) { + return inflater.inflate(R.layout.fragment_setup_step_network, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + rgPreset = view.findViewById(R.id.rg_net_preset); + tvInfo = view.findViewById(R.id.tv_net_info); + if (cidrPair == null) { + var used4 = new ArrayList(); + var used6 = new ArrayList(); + NetworkPresets.collectStoreNetworks(store(), null, used4, used6); + cidrPair = NetworkPresets.pickFreeCidrPair(used4, used6); + } + rgPreset.setOnCheckedChangeListener((g, id) -> updateInfo()); + updateInfo(); + activity.showFab(R.drawable.ic_arrow_forward, this::onNext); + } + + /** Name the selected preset would take, with a suffix if that one is somehow taken. */ + @NonNull + private String presetName(int checkedId) { + String base; + if (checkedId == R.id.rb_net_wifi) base = NAME_WIFI; + else if (checkedId == R.id.rb_net_gvisor) base = NAME_GVISOR; + else base = NAME_LINUX; + return NetworkPresets.uniqueName(store(), base); + } + + private void updateInfo() { + int checkedId = rgPreset.getCheckedRadioButtonId(); + var name = presetName(checkedId); + if (checkedId == R.id.rb_net_wifi) { + tvInfo.setText(getString(R.string.setup_network_info_wifi, name)); + return; + } + var unavailable = getString(R.string.setup_network_no_subnet); + var v4 = cidrPair != null ? cidrPair[0] : unavailable; + var v6 = cidrPair != null ? cidrPair[1] : unavailable; + tvInfo.setText(getString(checkedId == R.id.rb_net_gvisor + ? R.string.setup_network_info_gvisor + : R.string.setup_network_info_linux, name, v4, v6)); + } + + private void onNext() { + int checkedId = rgPreset.getCheckedRadioButtonId(); + NetworkConfig config; + if (checkedId == R.id.rb_net_wifi) { + config = NetworkPresets.wifiPseudoBridge(presetName(checkedId)); + } else { + if (cidrPair == null) { + Toast.makeText(activity, R.string.setup_network_no_subnet, LENGTH_LONG).show(); + return; + } + var type = checkedId == R.id.rb_net_gvisor ? BridgeType.GVISOR : BridgeType.LINUX; + config = NetworkPresets.routedNat(type, presetName(checkedId), cidrPair); + } + try { + NetworkConfigValidator.validate(config); + } catch (IllegalArgumentException e) { + Log.e(TAG, "Preset network failed validation", e); + Toast.makeText(activity, e.getMessage(), LENGTH_LONG).show(); + return; + } + store().add(config); + store().save(activity); + // Normally the daemon is not up yet and reads this out of networks.json when it starts; + // when it is already running, it has to be told, or it cannot resolve the network for + // the first VM that uses it. + NetworkActions.syncToDaemon(config); + created = true; + Toast.makeText(activity, + getString(R.string.setup_network_created, config.getName()), + LENGTH_SHORT).show(); + activity.onStepCompleted(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/PrivacyStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/PrivacyStepFragment.java index 2c61ab2b..d9400c56 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/PrivacyStepFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/PrivacyStepFragment.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.ui.setup.step; import android.os.Bundle; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/RootStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/RootStepFragment.java index a03eb10f..f1193f7b 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/RootStepFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/RootStepFragment.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.ui.setup.step; import static cn.classfun.droidvm.lib.utils.RunUtils.runList; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/SocStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/SocStepFragment.java index 4e5f20ce..454bc4a1 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/SocStepFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/SocStepFragment.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.ui.setup.step; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/StartStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/StartStepFragment.java index 3457dd9d..788bf5b5 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/StartStepFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/StartStepFragment.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.ui.setup.step; import static android.view.View.GONE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/StorageStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/StorageStepFragment.java index 1a81188b..0a910f4e 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/StorageStepFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/StorageStepFragment.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.ui.setup.step; import static android.view.View.GONE; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/VirtualizationStepFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/VirtualizationStepFragment.java index 5003a803..46c07a99 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/setup/step/VirtualizationStepFragment.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/setup/step/VirtualizationStepFragment.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.ui.setup.step; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/update/UpdateDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/update/UpdateDialog.java index 907b0df5..24a47af0 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/update/UpdateDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/update/UpdateDialog.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.ui.update; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/update/UpdateInfo.java b/app/src/main/java/cn/classfun/droidvm/ui/update/UpdateInfo.java index b3544e10..39b3cc5d 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/update/UpdateInfo.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/update/UpdateInfo.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.ui.update; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/update/VersionCheck.java b/app/src/main/java/cn/classfun/droidvm/ui/update/VersionCheck.java index e9e0ea39..9a13bfc8 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/update/VersionCheck.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/update/VersionCheck.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.ui.update; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/KernelModulePreflight.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/KernelModulePreflight.java new file mode 100644 index 00000000..ac45e546 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/KernelModulePreflight.java @@ -0,0 +1,124 @@ +// 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.ui.vm; + +import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.vm.ProtectedVM; +import cn.classfun.droidvm.lib.store.vm.VMScreenConfig; +import cn.classfun.droidvm.ui.main.settings.KernelModuleManager; + +/** + * Does this VM's configuration need a host kernel module that is not loaded? + * + *

    The modules under Settings are not decoration: each one supplies something a particular + * configuration will reach for at run time, and without it the VM fails in a way that looks like + * anything but a missing module - a guest that cannot see its own RAM, GPU allocations failing at + * random, a big VM refusing to boot with "out of memory" on a phone with memory to spare. This + * asks the question up front, per VM, from what the config actually turns on: + * + *

      + *
    • pseudo-unprotected RAM is SHARE'd to the running guest through + * {@code /dev/gunyah_share} - the Gunyah Host Share module; + *
    • GPU acceleration pins graphics memory ({@code gh_unmovable}, or the pin's migration + * fails and Vulkan reports out-of-device-memory) and hands it over as dma-bufs + * ({@code udmabuf}, which also lifts the 64 MB per-buffer cap); + *
    • a VM over {@value #KVCALLOC_MEMORY_MB} MB needs a page list too big to come out of one + * contiguous kcalloc on a fragmented phone - the kvcalloc fix. + *
    + * + *

    Which devices each rule applies to is not decided here. The rules above say only what + * the configuration reaches for; whether this phone has anything to load is the module + * list's own answer, and it is already a narrow one - the KMI directory picks the build for the + * running kernel and {@code match.json} drops the ones written for another SoC. A module the list + * does not offer is skipped without a word: it is not a missing prerequisite but one that was + * never part of the answer here, and the manage page would show nothing to load either. That is + * why the kvcalloc rule needs no "and only on the 8 Gen 3" of its own - the fix is built for the + * 6.1 GKI whose Gunyah driver has the bug, and for Qualcomm, so it can only ever surface there. + * + *

    Blocking - it reads {@code /proc/modules} through root; call it off the main thread. + */ +public final class KernelModulePreflight { + /** Module-name prefixes, as {@code match.json} keys them; the .ko adds a KMI suffix. */ + static final String GUNYAH_HOST_SHARE = "gunyah_host_share"; + static final String GH_UNMOVABLE = "gh_unmovable"; + static final String UDMABUF = "udmabuf"; + static final String GUNYAH_KVCALLOC = "gunyah_kvcalloc"; + + /** Above this much guest RAM the 6.1 Gunyah driver's page list stops fitting a kcalloc. */ + static final long KVCALLOC_MEMORY_MB = 2048; + + private KernelModulePreflight() { + } + + /** A module this VM wants, and the part of its configuration that wants it. */ + public static final class Missing { + /** The module's title as the Kernel Module list shows it. */ + @NonNull + public final String display; + @StringRes + public final int reason; + + Missing(@NonNull String display, @StringRes int reason) { + this.display = display; + this.reason = reason; + } + } + + /** + * The modules this configuration reaches for, module prefix to the reason string, in the + * order the dialog lists them. Pure, and deliberately device-blind: {@link #check} drops + * whatever this phone has no build for. + */ + @NonNull + static LinkedHashMap wantedBy(@NonNull DataItem item) { + var wanted = new LinkedHashMap(); + if (optEnum(item, "protected_vm", ProtectedVM.PROTECTED_WITHOUT_FIRMWARE) + == ProtectedVM.PSEUDO_UNPROTECTED) + wanted.put(GUNYAH_HOST_SHARE, R.string.vm_kernel_module_reason_pseudo_unprotected); + if (VMScreenConfig.hasGpuDevice(item)) { + wanted.put(GH_UNMOVABLE, R.string.vm_kernel_module_reason_gpu); + wanted.put(UDMABUF, R.string.vm_kernel_module_reason_gpu); + } + if (item.optLong("memory_mb", 512) > KVCALLOC_MEMORY_MB) + wanted.put(GUNYAH_KVCALLOC, R.string.vm_kernel_module_reason_memory); + return wanted; + } + + /** The wanted modules that apply to this device and are not loaded, in the order above. */ + @NonNull + public static List check(@NonNull Context ctx, @NonNull DataItem item) { + var wanted = wantedBy(item); + if (wanted.isEmpty()) return List.of(); + + var modules = KernelModuleManager.list(ctx); + var missing = new ArrayList(); + for (var want : wanted.entrySet()) { + KernelModuleManager.Module shipped = null; + boolean loaded = false; + for (var mod : modules) { + if (!mod.name.startsWith(want.getKey())) continue; + if (shipped == null) shipped = mod; + if (mod.loaded) { + loaded = true; + break; + } + } + if (shipped != null && !loaded) + missing.add(new Missing(shipped.display, want.getValue())); + } + return missing; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/LendMthpPreflight.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/LendMthpPreflight.java new file mode 100644 index 00000000..518237ed --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/LendMthpPreflight.java @@ -0,0 +1,76 @@ +// 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.ui.vm; + +import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import cn.classfun.droidvm.lib.data.HostKernel; +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.vm.LendMthpMode; +import cn.classfun.droidvm.lib.store.vm.VMBackend; +import cn.classfun.droidvm.lib.store.vm.VMHypervisor; + +/** + * Can this kernel LEND the way this VM is configured to? + * + *

    Guest RAM is handed to Gunyah as parcels. {@code chunked} splits the prepared region into + * 256 MB ones and lends each in its own slot; {@code single} lends the whole region at once. Which + * of the two works is not a preference but a property of the resource manager on the other side, + * and it changed between GKI series: 6.6 demand-pages a parcel, so many of them cost nothing until + * they are touched, while 6.1 commits every parcel as it arrives and answers the second or third + * one with NORESOURCE. A 3 GB VM is twelve parcels there, and it does not boot.

    + * + *

    The failure names none of this. crosvm reports {@code GH_VM_START failed} and exits with + * "failed to initialize virtual machine: No such device", which reads like a missing kernel module + * or a broken image, and the setting behind it is three tabs away in the editor.

    + * + *

    Worth a pre-start check rather than a better default because the value travels. A new VM gets + * the right mode from the device capability table, but a VM imported from a package carries the + * mode of the phone it was exported from -- and a package built on a 6.6 phone brings + * {@code chunked} to a 6.1 one, where it cannot work. That is the case this exists for.

    + * + *

    {@link #check} runs {@code uname}: call it off the main thread.

    + */ +public final class LendMthpPreflight { + private LendMthpPreflight() { + } + + /** + * Is this VM configured to lend in 256 MB parcels through Gunyah? + * + *

    Half the question, and the half that does not need the device. The other hypervisors do + * not lend at all -- the mode is passed to crosvm's Gunyah path alone -- so the setting is + * inert everywhere else and there is nothing to warn about.

    + */ + static boolean lendsChunked(@NonNull DataItem item) { + var backend = optEnum(item, "backend", VMBackend.DEFAULT); + var hypervisor = VMHypervisor.resolveConfigured( + backend, optEnum(item, "hypervisor", VMHypervisor.DEFAULT)); + return hypervisor == VMHypervisor.GUNYAH + && LendMthpMode.fromItem(item) == LendMthpMode.CHUNKED; + } + + /** + * Whether [item] would fail to start on a kernel of series [kernelMajorMinor]. + * + *

    Split out from {@link #check} so the rule can be read and tested without a phone under + * it. An unknown kernel answers false: the check exists to explain a failure, and inventing one + * for a kernel nobody could identify would put a dialog in front of a VM that starts fine.

    + */ + static boolean needsSingle(@NonNull DataItem item, @Nullable String kernelMajorMinor) { + return HostKernel.GKI_6_1.equals(kernelMajorMinor) && lendsChunked(item); + } + + /** {@link #needsSingle} against the running kernel. Runs {@code uname}; never throws. */ + public static boolean check(@NonNull DataItem item) { + try { + return needsSingle(item, HostKernel.majorMinor()); + } catch (Exception e) { + return false; + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/NicLeaseAllocator.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/NicLeaseAllocator.java index 018fb1b3..e6501e17 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/NicLeaseAllocator.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/NicLeaseAllocator.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.ui.vm; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -7,23 +10,20 @@ import androidx.annotation.NonNull; import java.util.HashSet; -import java.util.Set; -import java.util.UUID; -import cn.classfun.droidvm.lib.network.IPv4Network; -import cn.classfun.droidvm.lib.store.network.NetworkConfig; import cn.classfun.droidvm.lib.store.network.NetworkStore; -import cn.classfun.droidvm.lib.store.network.VlanConfig; +import cn.classfun.droidvm.lib.store.vm.NicLeaseOffsets; import cn.classfun.droidvm.lib.store.vm.VMConfig; import cn.classfun.droidvm.lib.store.vm.VMStore; /** * Fills in any unassigned DHCPv4 static-lease offset on a VM right before it * starts. A migrated config (or any lease enabled without an offset) carries an - * empty offset; this allocates the smallest free value from 64 up, skipping the - * VLAN's dynamic pool and any offset already used by another VM -- or this VM's - * own other NICs -- on the same network/VLAN, then persists the result so the - * guest IP stays stable across restarts. + * empty offset; this allocates the smallest free value from + * {@link NicLeaseOffsets#FIRST} up, skipping the VLAN's dynamic pool and any + * offset already used by another VM -- or this VM's own other NICs -- on the + * same network/VLAN, then persists the result so the guest IP stays stable + * across restarts. *

    * Allocation is app-side and persisted here; nothing else assigns offsets. *

    @@ -55,11 +55,16 @@ public static void resolveAndPersist(@NonNull VMConfig config, @NonNull Context if (network == null) return; var vlan = nic.resolveDhcpVlan(network); if (vlan == null || !vlan.isDhcp4Enabled()) return; - var net4 = vlan.getIpv4Network(); - if (net4 == null) return; - long offset = firstFree(usedOffsets(vmStore, config, selfId, network, vlan), - vlan, net4); + var used = new HashSet(); + vmStore.forEach((id, vm) -> { + // its persisted copy is stale vs the config in hand + if (id.equals(selfId)) return; + NicLeaseOffsets.addOffsets(used, vm, network, vlan, NicLeaseOffsets.Family.IPV4); + }); + NicLeaseOffsets.addOffsets(used, config, network, vlan, NicLeaseOffsets.Family.IPV4); + long offset = NicLeaseOffsets.resolve( + NicLeaseOffsets.FIRST, used, vlan, NicLeaseOffsets.Family.IPV4); if (offset < 0) { Log.w(TAG, fmt("No free DHCPv4 offset for a NIC on network %s", netId)); return; @@ -76,52 +81,4 @@ public static void resolveAndPersist(@NonNull VMConfig config, @NonNull Context Log.w(TAG, "Failed to allocate DHCPv4 lease offsets", e); } } - - /** - * Offsets already taken on {@code network}/{@code vlan} (IPv4): every other - * VM's NICs plus this VM's own already-assigned NICs, so a second NIC - * resolved in the same pass sees the first one's freshly set offset. - */ - @NonNull - private static Set usedOffsets( - @NonNull VMStore vmStore, @NonNull VMConfig config, @NonNull UUID selfId, - @NonNull NetworkConfig network, @NonNull VlanConfig vlan - ) { - var used = new HashSet(); - vmStore.forEach((id, vm) -> { - if (id.equals(selfId)) return; // its persisted copy is stale vs config - addOffsets(used, vm, network, vlan); - }); - addOffsets(used, config, network, vlan); - return used; - } - - private static void addOffsets( - @NonNull Set used, @NonNull VMConfig vm, - @NonNull NetworkConfig network, @NonNull VlanConfig vlan - ) { - var netIdStr = network.getId().toString(); - vm.forEachNic(nic -> { - if (!netIdStr.equals(nic.getNetworkId())) return; - if (!nic.isDhcp4LeaseEnabled() || !nic.hasDhcp4Offset()) return; - var nv = nic.resolveDhcpVlan(network); - if (nv == null || nv.getVlanId() != vlan.getVlanId()) return; - used.add(nic.getDhcp4Offset()); - }); - } - - /** Smallest offset >= 64 outside the dynamic pool and not already used. */ - private static long firstFree( - @NonNull Set used, @NonNull VlanConfig vlan, @NonNull IPv4Network net4 - ) { - long poolStart = vlan.getDhcp4OffsetStart(); - long poolEnd = vlan.getDhcp4OffsetEnd(); - long maxOffset = net4.totalAddresses() - 2; // addressAtOffset valid 1...total-2 - for (long c = 64; c <= maxOffset; c++) { - if (c >= poolStart && c <= poolEnd) continue; // skip the dynamic pool - if (used.contains(c)) continue; - return c; - } - return -1; - } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/VMActions.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/VMActions.java index 8804b5a8..19cbb465 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/VMActions.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/VMActions.java @@ -1,10 +1,17 @@ +// 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.ui.vm; import static android.widget.Toast.LENGTH_LONG; import static cn.classfun.droidvm.lib.Constants.PATH_BUILTIN_INITRD; import static cn.classfun.droidvm.lib.Constants.PATH_BUILTIN_KERNEL; import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum; +import static cn.classfun.droidvm.lib.utils.ImageUtils.hasInternalSnapshots; import static cn.classfun.droidvm.lib.utils.StringUtils.basename; +import static cn.classfun.droidvm.lib.utils.StringUtils.dirname; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; +import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; import static cn.classfun.droidvm.ui.main.settings.MainSettingsFragment.isAutoConsoleEnabled; import static cn.classfun.droidvm.ui.main.settings.MainSettingsFragment.isClearLogsBeforeStartEnabled; @@ -18,24 +25,40 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import org.json.JSONArray; import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.daemon.DaemonConnection; import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.disk.DiskStore; import cn.classfun.droidvm.lib.store.vm.BootConfig; +import cn.classfun.droidvm.lib.utils.ImageUtils; +import cn.classfun.droidvm.ui.disk.action.BackingChainLinker; +import cn.classfun.droidvm.ui.disk.tree.DiskTree; +import cn.classfun.droidvm.lib.store.vm.LendMthpMode; import cn.classfun.droidvm.lib.store.vm.VMBackend; +import cn.classfun.droidvm.lib.hugepage.PoolPreflight; +import cn.classfun.droidvm.ui.hugepage.HugePageActivity; +import cn.classfun.droidvm.ui.main.settings.KernelModuleDialog; import cn.classfun.droidvm.lib.store.vm.VMConfig; import cn.classfun.droidvm.lib.store.vm.VMStore; import cn.classfun.droidvm.lib.ui.UIContext; +import cn.classfun.droidvm.ui.disk.create.DiskCompress; import cn.classfun.droidvm.ui.disk.create.DiskFormat; import cn.classfun.droidvm.ui.disk.operation.DiskOperationActivity; import cn.classfun.droidvm.ui.vm.boot.BootMenuDialog; @@ -63,11 +86,263 @@ public static void createAndStart( @NonNull AtomicBoolean wantOpenConsole, @Nullable ConvertLauncher convertLauncher ) { - // crosvm can't read compressed qcow2 (the guest gets vda I/O errors - // and an unreadable partition table, so any root= hangs). Catch it - // before start and offer to convert; everything else starts normally. - guardCompressedDisks(config, mainHandler, ui, convertLauncher, - () -> startAfterGuard(config, mainHandler, ui, wantOpenConsole)); + // Pre-start guards, in order: internal snapshots (crosvm refuses the disk), a base + // image attached writable (writing would corrupt its overlays - any backend), a disk a + // running VM already holds, compressed clusters (crosvm boots to I/O errors), a host + // kernel module this configuration needs but nobody loaded, a LEND mode this kernel's + // resource manager will not accept, and a huge-page reserve too small to back this VM. + // Each prompts with its fix and chains to the next; everything else starts normally. The + // shared-disk guard may hand the rest of the chain a session copy of the config, so + // everything downstream uses what it passes on rather than `config`. + guardSnapshotDisks(config, mainHandler, ui, convertLauncher, + () -> guardLockedParents(config, mainHandler, ui, + () -> guardSharedRunning(config, mainHandler, ui, + started -> guardCompressedDisks(started, mainHandler, ui, convertLauncher, + () -> guardKernelModules(started, mainHandler, ui, + () -> guardLendMthp(started, mainHandler, ui, + () -> guardHugePagePool(started, mainHandler, ui, + () -> startAfterGuard(started, mainHandler, ui, + wantOpenConsole)))))))); + } + + /** + * Pre-start check: will this kernel's Gunyah resource manager take the parcels this VM is + * configured to lend? {@link LendMthpPreflight} answers that from the config and the running + * kernel series; on 6.1 the 256 MB parcels of {@code chunked} are refused a few in, and the VM + * dies at GH_VM_START naming nothing that would lead anyone to the setting. + * + *

    Unlike the module guard there is something to repair from the dialog, and repairing it is + * the point: the value is almost always inherited rather than chosen -- a VM package exported + * from a 6.6 phone carries the mode that phone needed -- so the offer is to correct it and + * keep the correction, in the store as well as in the config this start hands the daemon.

    + * + *

    Nobody to ask means the start proceeds untouched, as the other guards do. It will fail, + * the way it does today; silently rewriting a VM's memory configuration for an unattended + * start is a worse answer than the failure it would avoid.

    + */ + private static void guardLendMthp( + @NonNull VMConfig config, + @NonNull Handler mainHandler, + @NonNull UIContext ui, + @NonNull Runnable proceed + ) { + var appContext = ui.getContext().getApplicationContext(); + runOnPool(() -> { + boolean refused; + try { + refused = LendMthpPreflight.check(config.item); + } catch (Exception e) { + Log.w(TAG, "LEND mode preflight failed", e); + refused = false; + } + if (!refused) { + mainHandler.post(proceed); + return; + } + mainHandler.post(() -> promptLendMthp(config, appContext, mainHandler, ui, proceed)); + }); + } + + private static void promptLendMthp( + @NonNull VMConfig config, + @NonNull Context appContext, + @NonNull Handler mainHandler, + @NonNull UIContext ui, + @NonNull Runnable proceed + ) { + if (!ui.isAlive()) { + proceed.run(); + return; + } + var ctx = ui.getContext(); + new MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.vm_lend_mthp_refused_title) + .setMessage(R.string.vm_lend_mthp_refused_message) + .setPositiveButton(R.string.vm_lend_mthp_refused_fix, (d, w) -> + runOnPool(() -> { + applySingleLendMthp(appContext, config); + mainHandler.post(proceed); + })) + .setNeutralButton(R.string.vm_lend_mthp_refused_start_anyway, (d, w) -> proceed.run()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + /** + * Writes single-parcel LEND to this VM, in both places it has to land. + * + *

    The config the chain carries is what the daemon is given by vm_modify, and the store is + * what the VM list reloads from -- the same pair, and the same reason, as + * {@link #rememberChoice}: a change made only in memory starts this VM correctly and is gone by + * the next one. Does file I/O; call it off the main thread.

    + */ + private static void applySingleLendMthp( + @NonNull Context context, + @NonNull VMConfig config + ) { + config.item.set(LendMthpMode.KEY, LendMthpMode.SINGLE); + try { + var store = new VMStore(); + if (store.load(context)) { + var stored = store.findById(config.getId()); + if (stored != null) { + stored.item.set(LendMthpMode.KEY, LendMthpMode.SINGLE); + store.save(context); + } + } + } catch (Exception e) { + // The start still gets the corrected config; only the remembering failed. + Log.w(TAG, "failed to persist the LEND mode correction", e); + } + } + + /** + * Pre-start check: does this VM's configuration reach for a host kernel module nobody has + * loaded? {@link KernelModulePreflight} answers that from the config itself (pseudo- + * unprotected RAM, GPU acceleration, a VM too big for the 6.1 Gunyah driver's page list), + * counting only the modules the Kernel Module page would actually offer on this phone - a + * module built for another kernel or another SoC is not something to warn about. + * + *

    Unlike the disk guards there is nothing here to repair on the user's behalf: loading a + * module is a decision of its own, made in the Kernel Module page, which is why the offer is + * to go there rather than to fix it from this dialog. Starting anyway is a real answer too - + * a missing module costs the feature that wanted it, and does not corrupt anything - so it + * is what the countdown settles on for a start nobody is watching. + */ + private static void guardKernelModules( + @NonNull VMConfig config, + @NonNull Handler mainHandler, + @NonNull UIContext ui, + @NonNull Runnable proceed + ) { + var appContext = ui.getContext().getApplicationContext(); + runOnPool(() -> { + List missing; + try { + missing = KernelModulePreflight.check(appContext, config.item); + } catch (Exception e) { + Log.w(TAG, "kernel-module preflight failed", e); + missing = List.of(); + } + if (missing.isEmpty()) { + mainHandler.post(proceed); + return; + } + var found = missing; + mainHandler.post(() -> promptKernelModules(ui, found, proceed)); + }); + } + + private static void promptKernelModules( + @NonNull UIContext ui, + @NonNull List missing, + @NonNull Runnable proceed + ) { + if (!ui.isAlive()) { + // Nobody to ask: the start was requested, so honour it. + proceed.run(); + return; + } + var ctx = ui.getContext(); + var lines = new StringBuilder(); + for (var m : missing) + lines.append("\n- ").append(m.display).append(": ").append(ctx.getString(m.reason)); + var dialog = new MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.vm_kernel_module_title) + .setMessage(ctx.getString(R.string.vm_kernel_module_message, lines.toString())) + .setPositiveButton(R.string.vm_kernel_module_start_anyway, (d, w) -> proceed.run()) + .setNeutralButton(R.string.vm_kernel_module_manage, (d, w) -> showKernelModules(ctx)) + .setNegativeButton(android.R.string.cancel, null) + .create(); + dialog.show(); + // No response in 5s = "start anyway" (the chosen default), so an + // unattended start isn't blocked; the countdown shows on that button. + var startAnyway = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + if (startAnyway != null) + startAnyway.setText(ctx.getString(R.string.vm_kernel_module_start_countdown, 5)); + var timer = new CountDownTimer(5000, 1000) { + @Override + public void onTick(long ms) { + if (startAnyway != null) + startAnyway.setText(ctx.getString( + R.string.vm_kernel_module_start_countdown, + (int) Math.ceil(ms / 1000.0))); + } + + @Override + public void onFinish() { + dialog.dismiss(); + proceed.run(); + } + }; + // Any interaction (a button tap dismisses the dialog) stops the countdown. + dialog.setOnDismissListener(d -> timer.cancel()); + timer.start(); + } + + /** + * Opens the Kernel Module list (the same one Settings shows). This ends the start: loading a + * module is not instant, and re-deciding from a page the user is still working in would be + * guesswork - they start the VM again when they are done. + */ + private static void showKernelModules(@NonNull Context ctx) { + if (ctx instanceof FragmentActivity) { + KernelModuleDialog.show(((FragmentActivity) ctx).getSupportFragmentManager()); + return; + } + Log.w(TAG, "no fragment host for the kernel module list"); + Toast.makeText(ctx, R.string.vm_kernel_module_manage_unavailable, LENGTH_LONG).show(); + } + + /** + * Pre-start check: can the huge-page reserve back this VM right now? + * + *

    When it cannot, the memory crosvm hands the hypervisor comes from ordinary movable + * memory instead of the reserve's isolated folios, and handing that over means migrating it + * out of CMA first -- which on a tight phone has stalled the whole host for minutes or reset + * it outright. The pool refills a couple of seconds after a VM exits, so the usual cause is + * simply starting again too soon, and the usual fix is to wait a moment and retry. + * + *

    Foreground starts ask rather than wait: someone is looking at the screen, and they may + * well know something we do not (a smaller VM about to be shut down, a deliberate + * experiment). Background starts wait instead -- see {@code PoolPreflight.waitForPool}. + */ + private static void guardHugePagePool( + @NonNull VMConfig config, + @NonNull Handler mainHandler, + @NonNull UIContext ui, + @NonNull Runnable proceed + ) { + runOnPool(() -> { + var status = PoolPreflight.check(config.item); + if (status.isEnough()) { + mainHandler.post(proceed); + return; + } + mainHandler.post(() -> promptHugePageShort(ui, status, proceed)); + }); + } + + private static void promptHugePageShort( + @NonNull UIContext ui, + @NonNull PoolPreflight.Status status, + @NonNull Runnable proceed + ) { + if (!ui.isAlive()) { + // Nobody to ask: the start was requested, so honour it. + proceed.run(); + return; + } + var ctx = ui.getContext(); + new MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.vm_hugepage_short_title) + .setMessage(ctx.getString(R.string.vm_hugepage_short_message, + status.availMb(), status.neededMb(), status.shortMb())) + .setPositiveButton(R.string.vm_hugepage_short_settings, (d, w) -> + ctx.startActivity(new Intent(ctx, HugePageActivity.class))) + .setNeutralButton(R.string.vm_hugepage_short_start_anyway, (d, w) -> proceed.run()) + .setNegativeButton(android.R.string.cancel, null) + .show(); } private static void startAfterGuard( @@ -96,12 +371,291 @@ private static void startAfterGuard( } /** - * Pre-start check: for a crosvm VM with qcow2 disks, ask the daemon - * (lbx) which are zlib-compressed and therefore unreadable by crosvm. If - * any are, prompt to convert (decompress) them, then {@code proceed}; - * otherwise {@code proceed} straight away. A check failure never blocks a - * start -- a real boot would surface the problem. Callbacks arrive on the - * daemon thread, so UI work is posted to {@code mainHandler}. + * Pre-start check: a disk attached writable while registered overlays build on it must not + * be written - the overlays' copy-on-write base would shift under them - so offer to flip + * those attachments to read-only (persisted) and start. Backend-independent, unlike the + * crosvm-specific guards. The registry changes after this VM was configured (an overlay + * created elsewhere), which is why the disk editor's forced-readonly alone isn't enough. + */ + private static void guardLockedParents( + @NonNull VMConfig config, + @NonNull Handler mainHandler, + @NonNull UIContext ui, + @NonNull Runnable proceed + ) { + if (!ui.isAlive()) { + proceed.run(); + return; + } + var appContext = ui.getContext().getApplicationContext(); + runOnPool(() -> { + // Reconcile parent links from the images' own headers first, so registries predating + // the overlay tree (or images rebased outside the app) lock correctly from here on. + // Cheap and unambiguous at this point: a chain whose members are all present is + // exactly the case where linking is right, and a broken one can't boot anyway. + BackingChainLinker.repairAllBlocking(appContext, qcow2DiskPaths(config)); + var lockedPaths = new ArrayList(); + try { + var diskStore = new DiskStore(); + diskStore.load(appContext); + for (var path : VmDiskSharing.attachedPaths(config, true)) { + var registered = diskStore.findByPath(path); + if (registered != null && diskStore.hasChildren(registered.getId())) + lockedPaths.add(path); + } + } catch (Exception e) { + Log.w(TAG, "locked-parent check failed", e); + } + if (lockedPaths.isEmpty()) { + mainHandler.post(proceed); + return; + } + mainHandler.post(() -> { + if (!ui.isAlive()) return; + var ctx = ui.getContext(); + var files = new StringBuilder(); + for (var p : lockedPaths) files.append("\n- ").append(basename(p)); + new MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.vm_locked_disk_title) + .setMessage(ctx.getString(R.string.vm_locked_disk_message, files)) + .setPositiveButton(R.string.vm_locked_disk_readonly_start, (d, w) -> + runOnPool(() -> { + applyReadonly(appContext, config, lockedPaths); + mainHandler.post(proceed); + })) + .setNegativeButton(android.R.string.cancel, null) + .show(); + }); + }); + } + + /** + * Pre-start check: another VM may attach the same disk file, and while that VM is running + * both would have it open - two writers corrupt the image, and a reader under a writer sees + * it change underneath. Sharing on its own is fine, so this asks the daemon instead: with + * every other VM on the disk stopped, the start proceeds writable and untouched; with any of + * them anything else (starting, running, suspended, stopping, rebooting) the start is + * offered read-only for those disks. + * + *

    That flip lasts one boot: the copy handed to {@code proceed} is what the daemon is + * given, and the stored config keeps its writable slots, so the next start decides again + * from what the user saved. + */ + private static void guardSharedRunning( + @NonNull VMConfig config, + @NonNull Handler mainHandler, + @NonNull UIContext ui, + @NonNull Consumer proceed + ) { + var appContext = ui.getContext().getApplicationContext(); + runOnPool(() -> { + var held = new LinkedHashMap>(); + try { + var vmStore = new VMStore(); + if (vmStore.load(appContext)) { + var sharers = VmDiskSharing.sharersOf(vmStore, config.getId(), + VmDiskSharing.attachedPaths(config, true)); + if (!sharers.isEmpty()) { + var names = new LinkedHashSet(); + for (var vms : sharers.values()) names.addAll(vms); + // Blocking daemon query; a daemon that cannot be reached has no VM + // running either, so it reads as "nobody holds these". + var running = new HashSet<>(VmRunningQuery.inUseAmong(names)); + for (var entry : sharers.entrySet()) { + var holders = new ArrayList(); + for (var name : entry.getValue()) + if (running.contains(name)) holders.add(name); + if (!holders.isEmpty()) held.put(entry.getKey(), holders); + } + } + } + } catch (Exception e) { + Log.w(TAG, "shared-disk check failed", e); + } + if (held.isEmpty()) { + mainHandler.post(() -> proceed.accept(config)); + return; + } + mainHandler.post(() -> promptSharedRunning(config, ui, held, proceed)); + }); + } + + private static void promptSharedRunning( + @NonNull VMConfig config, + @NonNull UIContext ui, + @NonNull Map> held, + @NonNull Consumer proceed + ) { + Runnable start = () -> proceed.accept(readonlyForSession(config, held.keySet())); + // Nobody to ask: the start was requested and read-only is the answer that cannot corrupt + // anything, which is the same one the countdown below settles on. + if (!ui.isAlive()) { + start.run(); + return; + } + var ctx = ui.getContext(); + var files = new StringBuilder(); + for (var entry : held.entrySet()) + files.append("\n- ").append(basename(entry.getKey())) + .append(" (").append(String.join(", ", entry.getValue())).append(")"); + var dialog = new MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.vm_shared_disk_title) + .setMessage(ctx.getString(R.string.vm_shared_disk_message, files.toString())) + .setPositiveButton(R.string.vm_shared_disk_readonly_start, (d, w) -> start.run()) + .setNegativeButton(android.R.string.cancel, null) + .create(); + dialog.show(); + // No response in 5s = the read-only start (the only safe answer while the other VM + // holds the file), so an unattended start isn't blocked; the countdown shows on it. + var readonlyStart = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + if (readonlyStart != null) + readonlyStart.setText(ctx.getString(R.string.vm_shared_disk_readonly_countdown, 5)); + var timer = new CountDownTimer(5000, 1000) { + @Override + public void onTick(long ms) { + if (readonlyStart != null) + readonlyStart.setText(ctx.getString( + R.string.vm_shared_disk_readonly_countdown, + (int) Math.ceil(ms / 1000.0))); + } + + @Override + public void onFinish() { + dialog.dismiss(); + start.run(); + } + }; + // Any interaction (a button tap dismisses the dialog) stops the countdown. + dialog.setOnDismissListener(d -> timer.cancel()); + timer.start(); + } + + /** + * A copy of {@code config} with {@code paths} attached read-only, for this start only. The + * daemon is handed the copy (vm_create/vm_modify take whatever config the chain carries), so + * neither the VM store nor the config the UI holds records the flip. + */ + @NonNull + private static VMConfig readonlyForSession( + @NonNull VMConfig config, + @NonNull Set paths + ) { + var pathList = new ArrayList<>(paths); + try { + var session = new VMConfig(); + // Through JSON: DataItem's copy constructor shares the nested items, and setting + // read-only on those would write straight back into the caller's config. + session.item.set(config.toJson()); + setReadonlyOnDisks(session, pathList); + return session; + } catch (Exception e) { + // Starting writable is what this guard exists to prevent, so flip the live config + // instead and accept that the editor shows read-only until it reloads; nothing is + // saved either way. + Log.w(TAG, "session config copy failed; flipping the live config instead", e); + setReadonlyOnDisks(config, pathList); + return config; + } + } + + /** Flip the given attachments to read-only on the live config and the persisted VM store. */ + private static void applyReadonly( + @NonNull Context context, + @NonNull VMConfig config, + @NonNull List paths + ) { + setReadonlyOnDisks(config, paths); + try { + var store = new VMStore(); + if (store.load(store, context)) { + var stored = store.findById(config.getId()); + if (stored != null) { + setReadonlyOnDisks(stored, paths); + store.save(context); + } + } + } catch (Exception e) { + Log.w(TAG, "Failed to persist read-only flip", e); + } + } + + private static void setReadonlyOnDisks(@NonNull VMConfig config, @NonNull List paths) { + var disks = config.item.opt("disks", null); + if (disks == null || !disks.is(DataItem.Type.ARRAY)) return; + for (var disk : disks.asArray()) { + if (paths.contains(disk.optString("path", ""))) + disk.set("readonly", true); + } + } + + /** + * Pre-start check: crosvm refuses to open a qcow2 with internal snapshots for writing (it + * has no snapshot support, and writing would corrupt them), so a writable disk carrying + * snapshots means the VM cannot start at all. Offer to flatten it - the same convert the + * compression guard uses, which keeps the active state and drops the snapshots - and say so + * plainly, since that is destructive in a way the compression convert is not. There is no + * "start anyway": crosvm would just fail to open the disk. Read-only disks are skipped; + * crosvm accepts snapshots there. + */ + private static void guardSnapshotDisks( + @NonNull VMConfig config, + @NonNull Handler mainHandler, + @NonNull UIContext ui, + @Nullable ConvertLauncher convertLauncher, + @NonNull Runnable proceed + ) { + if (convertLauncher == null + || optEnum(config.item, "backend", VMBackend.DEFAULT) != VMBackend.CROSVM) { + proceed.run(); + return; + } + var qcow2 = qcow2DiskPaths(config, true); + if (qcow2.isEmpty()) { + proceed.run(); + return; + } + runOnPool(() -> { + var snapshotted = new JSONArray(); + for (var p : qcow2) + if (hasInternalSnapshots(p)) snapshotted.put(p); + if (snapshotted.length() == 0) + mainHandler.post(proceed); + else + mainHandler.post(() -> + promptFlattenSnapshots(ui, convertLauncher, snapshotted, proceed)); + }); + } + + private static void promptFlattenSnapshots( + @NonNull UIContext ui, + @NonNull ConvertLauncher convertLauncher, + @NonNull JSONArray snapshotted, + @NonNull Runnable proceed + ) { + if (!ui.isAlive()) return; + var ctx = ui.getContext(); + var files = new StringBuilder(); + for (int i = 0; i < snapshotted.length(); i++) { + var p = snapshotted.optString(i, ""); + if (!p.isEmpty()) files.append("\n- ").append(basename(p)); + } + new MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.vm_snapshot_disk_title) + .setMessage(ctx.getString(R.string.vm_snapshot_disk_message, files.toString())) + .setPositiveButton(R.string.vm_snapshot_disk_flatten, + (d, w) -> convertNext(ctx, convertLauncher, snapshotted, 0, proceed)) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + /** + * Pre-start check: for a crosvm VM with qcow2 disks, detect each disk's compression via + * qemu-img ({@link DiskCompress#detect}; ImageUtils runs it through the root run-context, + * so daemon-owned paths read fine) and prompt to convert any whose compression isn't in + * {@link DiskCompress#CROSVM_SUPPORTED} - the convert rewrites uncompressed, which is + * always supported - then {@code proceed}; otherwise {@code proceed} straight away. A + * detection failure reads as uncompressed, so a check hiccup never blocks a start (a real + * boot would surface the problem anyway). */ private static void guardCompressedDisks( @NonNull VMConfig config, @@ -120,24 +674,46 @@ private static void guardCompressedDisks( proceed.run(); return; } - var images = new JSONArray(); - for (var p : qcow2) images.put(p); - DaemonConnection.getInstance().buildRequest("disk_compat") - .put("images", images) - .onResponse(resp -> { - var compressed = resp.optJSONArray("compressed"); - if (compressed == null || compressed.length() == 0) - mainHandler.post(proceed); - else - mainHandler.post(() -> - promptConvert(config, ui, convertLauncher, compressed, proceed)); - }) - .onUnsuccessful(resp -> mainHandler.post(proceed)) - .onError(e -> { - Log.w(TAG, "disk_compat check failed", e); + runOnPool(() -> { + // Walk each disk's whole backing chain: crosvm reads the base images too, so a + // compressed cluster anywhere in the chain gives the guest the same I/O errors. + // The convert preserves an overlay's backing (appendConvert carries it), so + // decompressing a chain member never flattens it. + var unsupported = new JSONArray(); + var seen = new HashSet(); + for (var top : qcow2) + for (var p : backingChainOf(top)) + if (seen.add(p) && !DiskCompress.detect(p).isCrosvmSupported()) + unsupported.put(p); + if (unsupported.length() == 0) mainHandler.post(proceed); - }) - .invoke(); + else + mainHandler.post(() -> + promptConvert(config, ui, convertLauncher, unsupported, proceed)); + }); + } + + /** {@code path} plus every backing file under it (header walk, cycle- and depth-guarded). */ + @NonNull + private static List backingChainOf(@NonNull String path) { + var out = new ArrayList(); + var seen = new HashSet(); + var current = path; + for (int i = 0; i < DiskTree.MAX_DEPTH && seen.add(current); i++) { + out.add(current); + try { + var info = ImageUtils.getImageInfo(current); + var backing = info.optString("full-backing-filename", + info.optString("backing-filename", "")); + if (backing.isEmpty()) break; + if (!backing.startsWith("/")) + backing = pathJoin(dirname(current), backing); + current = backing; + } catch (Exception e) { + break; // unreadable member - the boot itself will surface it + } + } + return out; } private static void promptConvert( @@ -213,13 +789,26 @@ private static void convertNext( /** Absolute paths of the VM's qcow2 disks (the ones crosvm reads as blocks). */ @NonNull private static List qcow2DiskPaths(@NonNull VMConfig config) { + return qcow2DiskPaths(config, false); + } + + /** + * Absolute paths of the VM's qcow2 disks; with {@code writableOnly}, skips the ones attached + * read-only ({@code ro=true}), which crosvm opens under rules of their own - notably it + * accepts internal snapshots there, since reading never touches them. + */ + @NonNull + private static List qcow2DiskPaths(@NonNull VMConfig config, boolean writableOnly) { var out = new ArrayList(); var disks = config.item.opt("disks", null); if (disks != null && disks.is(DataItem.Type.ARRAY)) { for (var disk : disks.asArray()) { var path = disk.optString("path", ""); - if (!path.isEmpty() && DiskFormat.fromFilename(path) == DiskFormat.QCOW2) - out.add(path); + if (path.isEmpty() || DiskFormat.fromFilename(path) != DiskFormat.QCOW2) + continue; + if (writableOnly && disk.optBoolean("readonly", false)) + continue; + out.add(path); } } return out; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/VMCreateMenu.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/VMCreateMenu.java new file mode 100644 index 00000000..b1058d8a --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/VMCreateMenu.java @@ -0,0 +1,64 @@ +// 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.ui.vm; + +import android.content.Context; +import android.content.Intent; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.ui.MenuDialogBuilder; +import cn.classfun.droidvm.ui.disk.lxc.CreateLinuxVmActivity; +import cn.classfun.droidvm.ui.vm.edit.VMEditActivity; +import cn.classfun.droidvm.ui.vm.pkg.imports.VMPkgImportActivity; + +/** + * The "create a VM" chooser - Linux from a distro image, Windows (pointer to the image builder), + * import a package, or the full editor. One entry for the VM list's + button and the home + * screen's wizard card, so both offer exactly the same paths. + */ +public final class VMCreateMenu { + private VMCreateMenu() { + } + + public static void show(@NonNull Context context) { + MenuDialogBuilder.showSimple( + context, + R.string.vm_create_mode_title, + R.menu.menu_vm_create, + item -> { + var id = item.getItemId(); + if (id == R.id.menu_vm_create_linux) { + context.startActivity(new Intent(context, CreateLinuxVmActivity.class)); + } else if (id == R.id.menu_vm_create_windows) { + showWindowsVmUnavailableDialog(context); + } else if (id == R.id.menu_vm_create_import) { + context.startActivity(new Intent(context, VMPkgImportActivity.class)); + } else if (id == R.id.menu_vm_create_customize) { + context.startActivity(new Intent(context, VMEditActivity.class)); + } else { + return false; + } + return true; + } + ); + } + + private static void showWindowsVmUnavailableDialog(@NonNull Context context) { + new MaterialAlertDialogBuilder(context) + .setTitle(R.string.windows_vm_unavailable_title) + .setMessage(R.string.windows_vm_unavailable_message) + .setPositiveButton(R.string.windows_vm_open_script, (dialog, which) -> + context.startActivity(new Intent( + Intent.ACTION_VIEW, + Uri.parse("https://github.com/Droid-VM/win11-arm64-image-builder/blob/master/windows_build.ps1") + ))) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/VMDeletion.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/VMDeletion.java new file mode 100644 index 00000000..f556f6cd --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/VMDeletion.java @@ -0,0 +1,272 @@ +// 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.ui.vm; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; + +import android.content.Context; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.widget.CheckBox; +import android.widget.LinearLayout; +import android.widget.Toast; + +import androidx.annotation.NonNull; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; + +import java.io.File; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.daemon.DaemonConnection; +import cn.classfun.droidvm.lib.store.disk.DiskConfig; +import cn.classfun.droidvm.lib.store.disk.DiskStore; +import cn.classfun.droidvm.lib.store.vm.VMConfig; +import cn.classfun.droidvm.lib.store.vm.VMStore; + +/** Shared confirmation and optional writable-disk cleanup for deleting a VM. */ +public final class VMDeletion { + private static final String TAG = "VMDeletion"; + + private VMDeletion() { + } + + /** + * Shows the same delete options from both the VM list and VM details page. The disk option + * counts what deleting this VM would leave dangling - its writable attachments that no other + * VM references - so a disk the cleanup would keep is never offered in the first place; + * counted off the main thread because it reads the VM store. {@link #cleanupDisks} checks + * that again against the saved store, and keeps a base other overlays still build on. + */ + public static void confirm( + @NonNull Context context, + @NonNull VMConfig config, + @NonNull Consumer onConfirmed + ) { + var appContext = context.getApplicationContext(); + runOnPool(() -> { + int count = danglingWritablePaths(appContext, config).size(); + new Handler(Looper.getMainLooper()).post( + () -> show(context, config, count, onConfirmed)); + }); + } + + private static void show( + @NonNull Context context, + @NonNull VMConfig config, + int diskCount, + @NonNull Consumer onConfirmed + ) { + var layout = new LinearLayout(context); + var deleteDisks = new CheckBox(context); + deleteDisks.setText(context.getString(R.string.vm_delete_writable_disks, diskCount)); + deleteDisks.setEnabled(diskCount > 0); + int pad = (int) (16 * context.getResources().getDisplayMetrics().density); + layout.setPadding(pad, 0, pad, 0); + layout.addView(deleteDisks); + + new MaterialAlertDialogBuilder(context) + .setTitle(config.getName()) + .setMessage(R.string.vm_delete_confirm) + .setView(layout) + .setPositiveButton(R.string.vm_delete, + (dialog, which) -> onConfirmed.accept(deleteDisks.isChecked())) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + /** + * Releases the VM from the daemon, then optionally removes the disk registry entries and + * files that belonged to writable attachments. The caller must remove and save the VM store + * first so a fresh-store scan can reliably identify references from other VMs. + * + *

    {@code vm_delete} is idempotent on the daemon side: a VM it never managed (created but + * never started) is a successful no-op, so the only failure that keeps the files is a VM it + * could not stop. + */ + public static void releaseDaemonAndMaybeDeleteDisks( + @NonNull Context context, + @NonNull VMConfig config, + boolean deleteDisks, + boolean vmStoreSaved + ) { + var appContext = context.getApplicationContext(); + var paths = VmDiskSharing.attachedPaths(config, true); + var vmId = config.getId(); + Runnable cleanup = () -> runOnPool(() -> cleanupDisks(appContext, vmId, paths)); + Runnable skipped = () -> showResult(appContext, new Outcome(0, 0, 0, paths.size())); + + var request = DaemonConnection.getInstance().buildRequest("vm_delete") + .put("vm_id", config.getId().toString()); + if (deleteDisks && !paths.isEmpty()) { + if (!vmStoreSaved) { + skipped.run(); + } else { + // A successful response means DeleteHandler has stopped and removed the VM (or + // never had it). If the daemon cannot be reached, its owned VM processes cannot + // still be running either, matching the disk-operation run-state guard's + // semantics. + request + .onResponse(response -> cleanup.run()) + .onUnsuccessful(response -> { + Log.w(TAG, fmt("daemon refused vm_delete; keeping disk files: %s", + response.optString("message", ""))); + skipped.run(); + }) + .onError(error -> cleanup.run()); + } + } + request.invoke(); + } + + /** + * The writable attachments nothing else would reference once this VM is gone. A store that + * will not load answers with the unfiltered set: {@link #cleanupDisks} re-checks against the + * saved store before anything is removed, so the only cost is an offer that turns out to + * keep a file. + */ + @NonNull + private static LinkedHashSet danglingWritablePaths( + @NonNull Context context, + @NonNull VMConfig config + ) { + var paths = VmDiskSharing.attachedPaths(config, true); + try { + var vmStore = new VMStore(); + if (vmStore.load(context)) + paths.removeAll(VmDiskSharing.pathsAttachedByOthers(vmStore, config.getId())); + } catch (Exception e) { + Log.w(TAG, "Failed to check disks against the other VMs", e); + } + return paths; + } + + /** What happened to the requested files, by reason, for the one toast at the end. */ + private static final class Outcome { + final int deleted; + final int attachedByOthers; + final int basesOfOverlays; + final int failed; + + Outcome(int deleted, int attachedByOthers, int basesOfOverlays, int failed) { + this.deleted = deleted; + this.attachedByOthers = attachedByOthers; + this.basesOfOverlays = basesOfOverlays; + this.failed = failed; + } + } + + private static void cleanupDisks( + @NonNull Context context, + @NonNull UUID vmId, + @NonNull LinkedHashSet requestedPaths + ) { + if (requestedPaths.isEmpty()) return; + try { + var vmStore = new VMStore(); + if (!vmStore.load(context)) { + showResult(context, new Outcome(0, 0, 0, requestedPaths.size())); + return; + } + + // A writable disk must still be retained when any remaining VM references it, + // including through a read-only attachment. The deleted VM is out of the saved store + // by now; excluding its id as well keeps this right if that save ever races. + var referencedPaths = VmDiskSharing.pathsAttachedByOthers(vmStore, vmId); + var candidates = new LinkedHashSet(); + int attachedByOthers = 0; + for (var path : requestedPaths) { + if (referencedPaths.contains(path)) attachedByOthers++; + else candidates.add(path); + } + + var diskStore = new DiskStore(); + if (!diskStore.load(context)) { + showResult(context, new Outcome(0, attachedByOthers, 0, candidates.size())); + return; + } + + var safePaths = new ArrayList(); + int bases = 0; + int failed = 0; + boolean registryChanged = false; + boolean madeProgress; + do { + madeProgress = false; + for (var path : new ArrayList<>(candidates)) { + var registrations = registrationsForPath(diskStore, path); + if (registrations.size() > 1) { + // Duplicate registry entries are ambiguous; leave both the registry and + // file untouched instead of choosing one arbitrarily. + candidates.remove(path); + failed++; + continue; + } + if (registrations.isEmpty()) { + safePaths.add(path); + candidates.remove(path); + madeProgress = true; + continue; + } + var disk = registrations.get(0); + if (diskStore.hasChildren(disk.getId())) continue; + diskStore.removeById(disk.getId()); + safePaths.add(path); + candidates.remove(path); + registryChanged = true; + madeProgress = true; + } + } while (madeProgress); + // Whatever is left is a base of overlays this VM did not own (or a base whose + // overlays are themselves bases): those stay, with their registry entries. + bases = candidates.size(); + + // Persist the registry first. A save failure must never leave a registry entry that + // points at a file already removed from storage. + if (registryChanged && !diskStore.save(context)) { + showResult(context, new Outcome( + 0, attachedByOthers, bases, failed + safePaths.size())); + return; + } + + int deleted = 0; + for (var path : safePaths) { + var file = new File(path); + if (!file.exists() || file.isFile() && file.delete()) deleted++; + else failed++; + } + showResult(context, new Outcome(deleted, attachedByOthers, bases, failed)); + } catch (Exception e) { + Log.w(TAG, "Failed to clean up disks after VM deletion", e); + showResult(context, new Outcome(0, 0, 0, requestedPaths.size())); + } + } + + @NonNull + private static List registrationsForPath( + @NonNull DiskStore store, + @NonNull String path + ) { + var registrations = new ArrayList(); + for (int i = 0; i < store.size(); i++) { + var disk = store.get(i); + if (path.equals(disk.getFullPath())) registrations.add(disk); + } + return registrations; + } + + private static void showResult(@NonNull Context context, @NonNull Outcome outcome) { + var message = context.getString(R.string.vm_delete_disks_result, + outcome.deleted, outcome.attachedByOthers, outcome.basesOfOverlays, outcome.failed); + new Handler(Looper.getMainLooper()).post(() -> + Toast.makeText(context, message, Toast.LENGTH_LONG).show()); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/VmDiskSharing.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/VmDiskSharing.java new file mode 100644 index 00000000..033ba9e3 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/VmDiskSharing.java @@ -0,0 +1,123 @@ +// 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.ui.vm; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.disk.DiskBus; +import cn.classfun.droidvm.lib.store.enums.Enums; +import cn.classfun.droidvm.lib.store.vm.VMConfig; +import cn.classfun.droidvm.lib.store.vm.VMStore; +import cn.classfun.droidvm.ui.disk.tree.AttachmentCursors; + +/** + * Which VMs attach the same disk file. Two VMs writing one image corrupt it, and a reader under + * a writer sees the file change underneath - but that only bites while the other VM actually + * holds the file, so nothing here decides anything on its own: it answers "who else has this + * path in a slot", and each caller pairs that with what it is about to do. + * + *

    {@link VMActions} pairs it with the daemon's run state before a start - only a VM that is + * not stopped forces this start's attachments read-only - and {@link VMDeletion} uses it to keep + * a disk any remaining VM still references. The VM disk editor deliberately does not use it: a + * slot saved writable stays writable, and the start guard decides when sharing matters. + */ +public final class VmDiskSharing { + private VmDiskSharing() { + } + + /** + * The disk paths this VM attaches, in slot order. + * + * @param writableOnly skip the slots the guest cannot write - see {@link #isWritable} + */ + @NonNull + public static LinkedHashSet attachedPaths( + @NonNull VMConfig config, + boolean writableOnly + ) { + var paths = new LinkedHashSet(); + for (var slot : AttachmentCursors.diskSlots(config)) { + var path = slot.optString("path", ""); + if (path.isEmpty()) continue; + if (writableOnly && !isWritable(slot)) continue; + paths.add(path); + } + return paths; + } + + /** + * Whether this slot can be written. Beyond the read-only flag, a CDROM-bus slot never can: + * both backends open it read-only whatever the flag says (qemu {@code media=cdrom, + * readonly=on}, crosvm {@code ro=true,type=cdrom}), so it neither risks the image nor counts + * as a file this VM owns. + */ + private static boolean isWritable(@NonNull DataItem slot) { + return !slot.optBoolean("readonly", false) + && Enums.optEnum(slot, "bus", DiskBus.VIRTIO) != DiskBus.CDROM; + } + + /** + * Every path attached by a VM other than {@code excludeVmId}, read-only attachments + * included: a disk another VM reads is as much in use as one it writes. + */ + @NonNull + public static Set pathsAttachedByOthers( + @NonNull VMStore vms, + @Nullable UUID excludeVmId + ) { + var paths = new LinkedHashSet(); + for (int i = 0; i < vms.size(); i++) { + var vm = vms.get(i); + if (isExcluded(vm, excludeVmId)) continue; + paths.addAll(attachedPaths(vm, false)); + } + return paths; + } + + /** + * For each of {@code paths}, the names of the other VMs attaching it, in store order. Paths + * nobody else attaches are absent, so an empty result means nothing is shared. + */ + @NonNull + public static Map> sharersOf( + @NonNull VMStore vms, + @Nullable UUID excludeVmId, + @NonNull Collection paths + ) { + var out = new LinkedHashMap>(); + for (int i = 0; i < vms.size(); i++) { + var vm = vms.get(i); + if (isExcluded(vm, excludeVmId)) continue; + var attached = attachedPaths(vm, false); + for (var path : paths) { + if (!attached.contains(path)) continue; + var names = out.computeIfAbsent(path, p -> new ArrayList<>()); + var name = vm.getName(); + if (name != null && !names.contains(name)) names.add(name); + } + } + return out; + } + + /** A store loaded from a hand-edited file can hold an entry with no usable id. */ + private static boolean isExcluded(@NonNull VMConfig vm, @Nullable UUID excludeVmId) { + if (excludeVmId == null) return false; + try { + return excludeVmId.equals(vm.getId()); + } catch (Exception e) { + return false; + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/VmRunningQuery.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/VmRunningQuery.java new file mode 100644 index 00000000..01f4ec85 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/VmRunningQuery.java @@ -0,0 +1,59 @@ +// 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.ui.vm; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import cn.classfun.droidvm.lib.daemon.DaemonConnection; + +/** One-shot daemon queries about VM run state, for pre-operation checks. */ +public final class VmRunningQuery { + private VmRunningQuery() { + } + + /** + * Names among {@code candidates} whose VM is anything but stopped - starting, running, + * suspended, stopping or rebooting all hold the disk files open. Blocking (up to 5s) - call + * off the main thread. Daemon errors read as "none in use": these checks guard disk + * operations, and with the daemon down no VM can be running anyway. + */ + @NonNull + public static List inUseAmong(@NonNull Collection candidates) { + var inUse = new HashSet(); + var latch = new CountDownLatch(1); + DaemonConnection.getInstance().buildRequest("vm_list") + .onResponse(resp -> { + var arr = resp.optJSONArray("data"); + if (arr != null) { + for (int i = 0; i < arr.length(); i++) { + var obj = arr.optJSONObject(i); + if (obj != null + && !obj.optString("state").equalsIgnoreCase("stopped")) + inUse.add(obj.optString("name", "")); + } + } + latch.countDown(); + }) + .onUnsuccessful(resp -> latch.countDown()) + .onError(e -> latch.countDown()) + .invoke(); + try { + //noinspection ResultOfMethodCallIgnored + latch.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + var out = new ArrayList(); + for (var name : candidates) + if (inUse.contains(name)) out.add(name); + return out; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/boot/BootEntries.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/boot/BootEntries.java index de833127..7728040b 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/boot/BootEntries.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/boot/BootEntries.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.ui.vm.boot; import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/boot/BootMenuDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/boot/BootMenuDialog.java index cd84b5bb..0f50d9f8 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/boot/BootMenuDialog.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/boot/BootMenuDialog.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.ui.vm.boot; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -325,6 +328,11 @@ private static CharSequence warnedLabel( * protected-without-firmware) -- read from the stored config, where a * guest kernel without CONFIG_DMA_RESTRICTED_POOL cannot drive virtio. * Mirrors {@code VMEditBootTab.isProtectedVm}. + * + *

    {@code PSEUDO_UNPROTECTED} is deliberately not in this list. It is a + * protected VM to the hypervisor, but its RAM is shared to it rather than + * lent, so there is no bounce pool and a stock kernel boots -- the warning + * would be false there. */ private static boolean isProtectedVm(@NonNull VMConfig config) { var pvm = optEnum(config.item, "protected_vm", diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/console/VMConsoleActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/console/VMConsoleActivity.java index 12fcaba1..5f8cf03c 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/console/VMConsoleActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/console/VMConsoleActivity.java @@ -1,7 +1,8 @@ +// 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.ui.vm.console; -import static android.view.HapticFeedbackConstants.KEYBOARD_TAP; -import static android.view.KeyEvent.*; import static android.widget.Toast.LENGTH_SHORT; import static java.util.Objects.requireNonNull; import static cn.classfun.droidvm.lib.ui.MaterialMenu.setupToolbarMenu; @@ -11,6 +12,7 @@ import static cn.classfun.droidvm.lib.utils.ProcessUtils.shellKillProcess; import static cn.classfun.droidvm.lib.utils.RunUtils.escapedString; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.getEditText; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; import android.content.res.ColorStateList; @@ -21,12 +23,7 @@ import android.os.Handler; import android.os.Looper; import android.util.Log; -import android.view.KeyEvent; import android.view.MenuItem; -import android.view.MotionEvent; -import android.view.View; -import android.view.inputmethod.InputMethodManager; -import android.widget.Button; import android.widget.Toast; import androidx.activity.result.ActivityResultLauncher; @@ -36,10 +33,10 @@ import androidx.appcompat.app.AppCompatActivity; import com.google.android.material.appbar.MaterialToolbar; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputEditText; import com.termux.terminal.TerminalSession; import com.termux.terminal.TerminalSessionClient; -import com.termux.view.TerminalView; -import com.termux.view.TerminalViewClient; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; @@ -50,30 +47,31 @@ import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.daemon.DaemonConnection; -import cn.classfun.droidvm.lib.ui.ImeInsetsExempt; import cn.classfun.droidvm.lib.ui.termux.SimpleTerminalSessionClient; -import cn.classfun.droidvm.lib.ui.termux.TerminalFonts; -import cn.classfun.droidvm.lib.ui.termux.SimpleTerminalViewClient; +import cn.classfun.droidvm.lib.ui.termux.TerminalPanelView; import cn.classfun.droidvm.lib.utils.ShareUtils; -public final class VMConsoleActivity extends AppCompatActivity implements ImeInsetsExempt { +public final class VMConsoleActivity extends AppCompatActivity { private static final String TAG = "VMConsoleActivity"; public static final String EXTRA_VM_ID = "vm_id"; public static final String EXTRA_VM_NAME = "vm_name"; public static final String EXTRA_STREAM = "stream"; public static final String EXTRA_LOGS = "logs"; - private static final String DEFAULT_STREAM = "uart"; - private static final String PREFS_NAME = "droidvm_prefs"; - private static final String KEY_FONT_SIZE = "console_font_size"; - private static final float MIN_FONT_SIZE = 2; - private static final float MAX_FONT_SIZE = 48; - private static final float DEFAULT_FONT_SIZE = 5; + /** Initial value of the filter; empty or absent opens the page unfiltered. */ + public static final String EXTRA_FILTER = "filter"; + // Every backend registers stdio; "uart" only exists on the QEMU backend these days, and + // the crosvm serial streams are named per port (serialN/sbsaN/vconN) so none is a safe + // universal fallback. + private static final String DEFAULT_STREAM = "stdio"; private final Handler mainHandler = new Handler(Looper.getMainLooper()); private ActivityResultLauncher saveLogLauncher; - private TerminalView terminalView; + private MaterialToolbar toolbar; + private TerminalPanelView terminalPanel; private TerminalSession terminalSession; - private boolean ctrlDown = false; - private boolean altDown = false; + /** True for the history dump, false for the live console. Decides the command either way. */ + private boolean logsMode = false; + /** The text every shown line must contain; empty is no filter. Never null. */ + private String filter = ""; public String vmId; public String vmName; public String streamName; @@ -82,55 +80,11 @@ public final class VMConsoleActivity extends AppCompatActivity implements ImeIns @Override public void onTextChanged(@NonNull TerminalSession s) { mainHandler.post(() -> { - if (terminalView != null) - terminalView.onScreenUpdated(); + if (terminalPanel != null) terminalPanel.refresh(); }); } }; - private float currentFontSize = DEFAULT_FONT_SIZE; - private final TerminalViewClient viewClient = new SimpleTerminalViewClient() { - @Override - public float onScale(float scale) { - var dampened = 1.0f + (scale - 1.0f) * 0.1f; - currentFontSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, currentFontSize * dampened)); - if (terminalView != null) { - var density = getResources().getDisplayMetrics().density; - terminalView.setTextSize((int) (currentFontSize * density)); - } - return dampened; - } - - @Override - public void onSingleTapUp(MotionEvent e) { - var imm = getSystemService(InputMethodManager.class); - if (imm != null && terminalView != null) { - terminalView.requestFocus(); - imm.showSoftInput(terminalView, 0); - } - } - - @Override - public boolean readControlKey() { - if (ctrlDown) { - ctrlDown = false; - updateToggleButtons(); - return true; - } - return false; - } - - @Override - public boolean readAltKey() { - if (altDown) { - altDown = false; - updateToggleButtons(); - return true; - } - return false; - } - }; - @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -141,49 +95,120 @@ protected void onCreate(Bundle savedInstanceState) { vmId = intent.getStringExtra(EXTRA_VM_ID); vmName = intent.getStringExtra(EXTRA_VM_NAME); streamName = intent.getStringExtra(EXTRA_STREAM); - var logs = intent.getBooleanExtra(EXTRA_LOGS, false); + logsMode = intent.getBooleanExtra(EXTRA_LOGS, false); + filter = intent.getStringExtra(EXTRA_FILTER); if (vmId == null) vmId = ""; if (vmName == null) vmName = ""; + if (filter == null) filter = ""; if (streamName == null || streamName.isEmpty()) streamName = DEFAULT_STREAM; - MaterialToolbar toolbar = findViewById(R.id.toolbar); - toolbar.setTitle(fmt("%s - %s", vmName, streamName)); + toolbar = findViewById(R.id.toolbar); + updateTitle(); toolbar.setNavigationOnClickListener(v -> finish()); var item = setupToolbarMenu(toolbar, R.menu.menu_vm_console, this::onMenuItemClicked); item.setIconTintList(ColorStateList.valueOf(Color.WHITE)); item.setIconTintMode(PorterDuff.Mode.SRC_IN); - terminalView = findViewById(R.id.terminal_view); - terminalView.setTerminalViewClient(viewClient); - var consoleBin = getAssetBinaryPath("droidvm"); - var shell = findExecute("su", "/system/bin/su"); - var cwd = getFilesDir().getAbsolutePath(); - var cmd = fmt( - logs ? "%s logs %s %s; sleep 2" : "exec %s console --raw %s %s", - escapedString(consoleBin), + terminalPanel = findViewById(R.id.terminal_panel); + terminalPanel.setInteractive(true); + startSession(); + } + + /** + * The shell line the terminal runs, for the current mode and filter. + * + *

    Filtering is a pipe because the page has nothing else to filter: what is on screen is a + * pty a subprocess writes to, not a buffer this activity holds. {@code grep -F} is toybox's, + * and {@code --} keeps a filter that starts with a dash from being read as an option.

    + * + *

    No {@code --line-buffered} on the live path. toybox 0.8.12-android does accept the + * option, but its grep already flushes each matching line as it produces it -- measured on + * device, a matching line was in a redirected file two seconds into a producer that had not + * exited, with and without the option -- so it would buy nothing here while breaking the page + * outright on any toybox whose grep lacks it, since an unknown long option is exit 2 rather + * than a warning.

    + */ + @NonNull + private String buildCommand() { + var base = fmt( + logsMode ? "%s logs %s %s" : "%s console --raw %s %s", + escapedString(getAssetBinaryPath("droidvm")), escapedString(vmId), escapedString(streamName) ); - var args = new String[]{"su", "-c", cmd}; + if (!filter.isEmpty()) + base = fmt("%s | grep -F -- %s", base, escapedString(filter)); + // The dump's `sleep 2` keeps the last lines on screen after the command ends. The live + // path replaces the shell instead, which it can only do while there is no pipeline for + // the shell to wait on. + if (logsMode) return fmt("%s; sleep 2", base); + return filter.isEmpty() ? fmt("exec %s", base) : base; + } + + private void startSession() { + stopSession(); + var shell = findExecute("su", "/system/bin/su"); + var cwd = getFilesDir().getAbsolutePath(); + var args = new String[]{"su", "-c", buildCommand()}; var env = new String[]{ "TERM=xterm-256color", "PATH=/system/bin", fmt("HOME=%s", cwd), }; - currentFontSize = loadFontSize(); - var density = getResources().getDisplayMetrics().density; var session = new TerminalSession(shell, cwd, args, env, null, sessionClient); terminalSession = session; - terminalView.attachSession(session); - terminalView.setTextSize((int) (currentFontSize * density)); - TerminalFonts.apply(terminalView); - terminalView.setFocusable(true); - terminalView.setFocusableInTouchMode(true); - terminalView.requestFocus(); - setupExtraKeys(); + terminalPanel.attachSession(session); + } + + private void stopSession() { + if (terminalSession == null) return; + try { + if (terminalSession.isRunning()) + shellKillProcess(terminalSession.getPid(), SIGHUP); + } catch (Exception ignored) { + } + terminalSession.finishIfRunning(); + terminalPanel.clearSession(terminalSession); + terminalSession = null; + } + + private void updateTitle() { + toolbar.setTitle(filter.isEmpty() + ? fmt("%s - %s", vmName, streamName) + : getString(R.string.logs_title_filtered, vmName, streamName, filter)); + } + + /** + * Applies a new filter, which means running the command again -- for the dump that re-reads + * the same history, for the live console it is a reconnect and the backlog it had on screen + * is not read again. + */ + private void applyFilter(@NonNull String value) { + if (value.equals(filter)) return; + filter = value; + updateTitle(); + startSession(); + } + + private void showFilterDialog() { + var view = getLayoutInflater().inflate(R.layout.dialog_console_filter, null); + TextInputEditText etFilter = view.findViewById(R.id.et_console_filter); + etFilter.setText(filter); + etFilter.setSelection(filter.length()); + new MaterialAlertDialogBuilder(this) + .setTitle(R.string.logs_filter_title) + .setMessage(R.string.logs_filter_message) + .setView(view) + .setPositiveButton(android.R.string.ok, (d, w) -> applyFilter(getEditText(etFilter))) + .setNegativeButton(android.R.string.cancel, null) + .setNeutralButton(R.string.logs_filter_show_all, (d, w) -> applyFilter("")) + .show(); } private boolean onMenuItemClicked(@NonNull MenuItem item) { int id = item.getItemId(); - if (id == R.id.action_save_log) { + if (id == R.id.action_filter) { + showFilterDialog(); + return true; + } else if (id == R.id.action_save_log) { saveLogToFile(); return true; } else if (id == R.id.action_share_log) { @@ -196,94 +221,10 @@ private boolean onMenuItemClicked(@NonNull MenuItem item) { return false; } - @Override - protected void onPause() { - super.onPause(); - saveFontSize(); - } - - private float loadFontSize() { - var saved = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) - .getFloat(KEY_FONT_SIZE, DEFAULT_FONT_SIZE); - return Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, saved)); - } - - private void saveFontSize() { - getSharedPreferences(PREFS_NAME, MODE_PRIVATE) - .edit() - .putFloat(KEY_FONT_SIZE, currentFontSize) - .apply(); - } - @Override protected void onDestroy() { super.onDestroy(); - if (terminalSession != null) try { - if (terminalSession.isRunning()) - shellKillProcess(terminalSession.getPid(), SIGHUP); - } catch (Exception ignored) { - } - terminalSession = null; - } - - private void sendKey(int keyCode) { - if (terminalSession != null) { - var down = new KeyEvent(ACTION_DOWN, keyCode); - var up = new KeyEvent(ACTION_UP, keyCode); - terminalView.onKeyDown(keyCode, down); - terminalView.onKeyUp(keyCode, up); - } - } - - private void sendChar(char ch) { - if (terminalSession != null) - terminalSession.write(String.valueOf(ch)); - } - - private void updateToggleButtons() { - setToggleStyle(findViewById(R.id.btn_ctrl), ctrlDown); - setToggleStyle(findViewById(R.id.btn_alt), altDown); - } - - private void setToggleStyle(Button btn, boolean active) { - if (btn == null) return; - if (active) { - btn.setBackgroundColor(getColor(R.color.extra_key_bg_active)); - btn.setTextColor(getColor(R.color.extra_key_text_active)); - } else { - btn.setBackground(null); - btn.setTextColor(getColor(R.color.extra_key_text)); - } - } - - private void setupExtraKeys() { - setExtraKeyClick(R.id.btn_esc, v -> sendKey(KEYCODE_ESCAPE)); - setExtraKeyClick(R.id.btn_slash, v -> sendChar('/')); - setExtraKeyClick(R.id.btn_dash, v -> sendChar('-')); - setExtraKeyClick(R.id.btn_home, v -> sendKey(KEYCODE_MOVE_HOME)); - setExtraKeyClick(R.id.btn_up, v -> sendKey(KEYCODE_DPAD_UP)); - setExtraKeyClick(R.id.btn_end, v -> sendKey(KEYCODE_MOVE_END)); - setExtraKeyClick(R.id.btn_pgup, v -> sendKey(KEYCODE_PAGE_UP)); - setExtraKeyClick(R.id.btn_tab, v -> sendKey(KEYCODE_TAB)); - setExtraKeyClick(R.id.btn_ctrl, v -> { - ctrlDown = !ctrlDown; - updateToggleButtons(); - }); - setExtraKeyClick(R.id.btn_alt, v -> { - altDown = !altDown; - updateToggleButtons(); - }); - setExtraKeyClick(R.id.btn_left, v -> sendKey(KEYCODE_DPAD_LEFT)); - setExtraKeyClick(R.id.btn_down, v -> sendKey(KEYCODE_DPAD_DOWN)); - setExtraKeyClick(R.id.btn_right, v -> sendKey(KEYCODE_DPAD_RIGHT)); - setExtraKeyClick(R.id.btn_pgdn, v -> sendKey(KEYCODE_PAGE_DOWN)); - } - - private void setExtraKeyClick(int id, View.OnClickListener listener) { - findViewById(id).setOnClickListener(v -> { - v.performHapticFeedback(KEYBOARD_TAP); - listener.onClick(v); - }); + stopSession(); } private void saveLogToFile() { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/console/VMConsoleRouter.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/console/VMConsoleRouter.java index a8a01463..d3f33d86 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/console/VMConsoleRouter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/console/VMConsoleRouter.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.ui.vm.console; import android.content.Context; @@ -11,18 +14,24 @@ import java.util.UUID; import cn.classfun.droidvm.lib.daemon.DaemonConnection; +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.vm.DisplayExporter; import cn.classfun.droidvm.lib.store.vm.VMConfig; +import cn.classfun.droidvm.lib.store.vm.VMScreenConfig; import cn.classfun.droidvm.ui.vm.display.nativedisplay.display.VMNativeDisplayActivity; import cn.classfun.droidvm.ui.vm.display.vnc.base.BaseVncActivity; import cn.classfun.droidvm.ui.vm.display.vnc.display.VMVncDisplayActivity; -import cn.classfun.droidvm.ui.vm.display.vnc.display.VMVncPresentationActivity; /** * Shared "open the VM's default view" routing, so the VM-info screen (a console * button) and the VM-list auto-open-after-start pick the same thing: - * native display, else VNC, else the serial console (uart, then stdio). Keeping + * the first bound screen, else the serial console (uart, then stdio). Keeping * this in one place is why both paths agree instead of the list always opening * the UART console regardless of the VM's display. + * + *

    Every display view is opened for one screen, named explicitly, because the VM can have two + * and they can be exported differently. The default picks the first one bound -- the only one a + * single-screen VM has; the chooser is where a two-screen VM is asked which.

    */ public final class VMConsoleRouter { private VMConsoleRouter() { @@ -34,16 +43,17 @@ private VMConsoleRouter() { */ public static void openDefault(@NonNull Context ctx, @NonNull UUID vmId, @NonNull VMConfig config, boolean running) { - var item = config.item; - if (item.optBoolean("native_display_enabled", false)) { - openNative(ctx, vmId, config); - return; - } - if (item.optBoolean("vnc_enabled", false)) { - openVnc(ctx, vmId, config); + var bound = VMScreenConfig.boundOf(config.item); + if (!bound.isEmpty()) { + var screen = bound.get(0); + if (screen.getExporter() == DisplayExporter.NATIVE) + openNative(ctx, vmId, config, screen.id); + else + openVnc(ctx, vmId, config, screen.id); return; } - // Serial console: ask the daemon which streams exist, prefer uart then stdio. + // Serial console: ask the daemon which streams exist. Prefer a real guest serial port + // (the app-console serial streams, or QEMU's legacy "uart") over the process stdio. DaemonConnection.getInstance().buildRequest("vm_console_list") .put("vm_id", vmId.toString()) .onResponse(resp -> { @@ -56,7 +66,13 @@ public static void openDefault(@NonNull Context ctx, @NonNull UUID vmId, if (!n.isEmpty()) names.add(n); } if (names.contains("uart")) stream = "uart"; - else if (names.contains("stdio")) stream = "stdio"; + if (stream == null) + for (var n : names) + if (n.matches("(serial|sbsa|vcon)[0-9]+")) { + stream = n; + break; + } + if (stream == null && names.contains("stdio")) stream = "stdio"; } if (stream == null) return; final var s = stream; @@ -78,27 +94,51 @@ public static void openConsole(@NonNull Context ctx, @NonNull UUID vmId, ctx.startActivity(intent); } - public static void openNative(@NonNull Context ctx, @NonNull UUID vmId, @NonNull VMConfig config) { + /** + * Opens [screenId]'s native display. The screen id travels in the intent rather than being + * inferred here, because the display service name is derived from it and a wrong guess is a + * console that waits forever on a binder nobody registers. + */ + public static void openNative(@NonNull Context ctx, @NonNull UUID vmId, + @NonNull VMConfig config, @NonNull String screenId) { var item = config.item; var intent = new Intent(ctx, VMNativeDisplayActivity.class); intent.putExtra(VMNativeDisplayActivity.EXTRA_VM_ID, vmId.toString()); intent.putExtra(VMNativeDisplayActivity.EXTRA_VM_NAME, config.getName()); - intent.putExtra(VMNativeDisplayActivity.EXTRA_WIDTH, item.optLong("display_width", 1280)); - intent.putExtra(VMNativeDisplayActivity.EXTRA_HEIGHT, item.optLong("display_height", 720)); + intent.putExtra(VMNativeDisplayActivity.EXTRA_SCREEN, screenId); + intent.putExtra(VMNativeDisplayActivity.EXTRA_INPUT_ENABLED, inputEnabled(item, screenId)); + // The screen's own size, not the VM's: the two screens have different geometry now, and + // the console is showing exactly one of them. + var screen = VMScreenConfig.find(item, screenId); + intent.putExtra(VMNativeDisplayActivity.EXTRA_WIDTH, + screen != null ? screen.getWidth() : VMScreenConfig.DEFAULT_WIDTH); + intent.putExtra(VMNativeDisplayActivity.EXTRA_HEIGHT, + screen != null ? screen.getHeight() : VMScreenConfig.DEFAULT_HEIGHT); ctx.startActivity(intent); } - public static void openVnc(@NonNull Context ctx, @NonNull UUID vmId, @NonNull VMConfig config) { - var intent = new Intent(ctx, VMVncDisplayActivity.class); - intent.putExtra(BaseVncActivity.EXTRA_VM_ID, vmId.toString()); - intent.putExtra(BaseVncActivity.EXTRA_VM_NAME, config.getName()); - ctx.startActivity(intent); + /** + * Whether [screenId] was started with its own absolute input devices. + * + *

    This is what the config says now, which is only the same as what the running VM has if + * nobody edited it since. The console uses it to explain dead touch input, never to decide + * where to send events -- that answer comes from the daemon, which knows which sockets it + * actually bound.

    + */ + private static boolean inputEnabled(@NonNull DataItem item, @NonNull String screenId) { + var screen = VMScreenConfig.find(item, screenId); + return screen == null || screen.isInputEnabled(); } - public static void openVncExt(@NonNull Context ctx, @NonNull UUID vmId, @NonNull VMConfig config) { - var intent = new Intent(ctx, VMVncPresentationActivity.class); + public static void openVnc(@NonNull Context ctx, @NonNull UUID vmId, + @NonNull VMConfig config, @NonNull String screenId) { + var intent = new Intent(ctx, VMVncDisplayActivity.class); intent.putExtra(BaseVncActivity.EXTRA_VM_ID, vmId.toString()); intent.putExtra(BaseVncActivity.EXTRA_VM_NAME, config.getName()); + intent.putExtra(BaseVncActivity.EXTRA_SCREEN, screenId); + intent.putExtra(BaseVncActivity.EXTRA_INPUT_ENABLED, inputEnabled(config.item, screenId)); ctx.startActivity(intent); } + // openVncExt retired: projecting to an external display is now an action inside the VNC + // console's own menu (VMVncDisplayActivity#projectToExternalDisplay), not a chooser entry. } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/BaseExtraKeysAdapter.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/BaseExtraKeysAdapter.java index f0cc647e..8a742154 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/BaseExtraKeysAdapter.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/BaseExtraKeysAdapter.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.ui.vm.display.base; import static android.view.KeyEvent.KEYCODE_ALT_LEFT; @@ -18,6 +21,10 @@ public abstract class BaseExtraKeysAdapter implements KeyListener { protected final DisplayExtraKeysPanel panel; private boolean ctrlSticky, altSticky, shiftSticky, winSticky; + // Keys currently held via onKey(). One-shot modifiers wrap the whole hold group (applied on + // the first key down, released after the last key up) so simultaneous holds like W+A don't + // lose their modifiers halfway through. + private int heldKeys; protected BaseExtraKeysAdapter(@NonNull DisplayExtraKeysPanel panel) { this.panel = panel; @@ -54,13 +61,19 @@ public void applyModifiers(boolean down) { } } - /** Taps a key with the active modifiers wrapped around it. */ - protected void tapKey(int androidKeyCode) { - if (!isReady()) return; - applyModifiers(true); - emitKey(androidKeyCode, true); - emitKey(androidKeyCode, false); - applyModifiers(false); + @Override + public void onKey(int androidKeyCode, boolean down) { + if (!isReady()) { + heldKeys = 0; + return; + } + if (down) { + if (heldKeys++ == 0) applyModifiers(true); + emitKey(androidKeyCode, true); + } else { + emitKey(androidKeyCode, false); + if (heldKeys > 0 && --heldKeys == 0) applyModifiers(false); + } } @Override diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DaemonDisplayAttach.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DaemonDisplayAttach.java new file mode 100644 index 00000000..2d347c85 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DaemonDisplayAttach.java @@ -0,0 +1,147 @@ +// 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.ui.vm.display.base; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Handler; +import android.os.IBinder; +import android.os.RemoteException; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.UUID; + +import cn.classfun.droidvm.display.INativeDisplayRootService; +import cn.classfun.droidvm.lib.store.vm.NativeDisplay; + +/** + * Acquires the daemon's broker binder ({@link INativeDisplayRootService}) for a display activity. + * The binder can't ride the JSON-RPC channel, so the flow is: send {@code display_attach} with a + * per-attach random nonce, and the daemon (uid=0) broadcasts the binder back; only the broadcast + * carrying our nonce is accepted, so another app spoofing the exported action can't slip us a + * fake broker. Retries while the daemon connection is still coming up. Shared by the native and + * VNC display activities, which use the binder for the direct evdev input sink (and the native + * path additionally for the per-VM display binder lookup). + */ +public final class DaemonDisplayAttach { + private static final String TAG = "DaemonDisplayAttach"; + private static final int MAX_ATTEMPTS = 10; + private static final long RETRY_DELAY_MS = 500; + + public interface Listener { + /** The broker binder arrived (fired once, on the main thread). */ + void onAttached(@NonNull INativeDisplayRootService service); + + /** The daemon died after attach; direct paths must fall back (main thread). */ + void onLost(); + } + + private final Activity activity; + private final Handler mainHandler; + private final Listener listener; + private final String nonce = UUID.randomUUID().toString(); + @Nullable + private INativeDisplayRootService service; + private boolean receiverRegistered; + private boolean attached; + + private final BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + var bundle = intent.getBundleExtra(NativeDisplay.EXTRA_BUNDLE); + if (bundle == null || !nonce.equals(bundle.getString(NativeDisplay.EXTRA_NONCE))) { + return; + } + var binder = bundle.getBinder(NativeDisplay.EXTRA_BINDER); + if (binder != null) { + onBinderReceived(binder); + } + } + }; + + private final IBinder.DeathRecipient deathRecipient; + + public DaemonDisplayAttach(@NonNull Activity activity, @NonNull Handler mainHandler, + @NonNull Listener listener) { + this.activity = activity; + this.mainHandler = mainHandler; + this.listener = listener; + this.deathRecipient = () -> mainHandler.post(() -> { + Log.w(TAG, "daemon broker binder died"); + service = null; + listener.onLost(); + }); + } + + /** Registers the nonce-matched broadcast receiver and requests the broker binder. */ + public void start() { + activity.registerReceiver(receiver, + new IntentFilter(NativeDisplay.BINDER_BROADCAST_ACTION), Context.RECEIVER_EXPORTED); + receiverRegistered = true; + request(MAX_ATTEMPTS); + } + + /** The broker binder, or null before attach / after the daemon died. */ + @Nullable + public INativeDisplayRootService getService() { + return service; + } + + public void stop() { + if (receiverRegistered) { + try { + activity.unregisterReceiver(receiver); + } catch (Exception ignored) { + } + receiverRegistered = false; + } + if (service != null) { + try { + service.asBinder().unlinkToDeath(deathRecipient, 0); + } catch (Exception ignored) { + } + } + service = null; + } + + private void request(int attemptsLeft) { + if (attached || activity.isFinishing()) { + return; + } + cn.classfun.droidvm.lib.daemon.DaemonConnection.getInstance().buildRequest("display_attach") + .put("nonce", nonce) + .onError(e -> retry(attemptsLeft)) + .onUnsuccessful(r -> retry(attemptsLeft)) + .invoke(); + } + + private void retry(int attemptsLeft) { + if (attemptsLeft <= 0) { + Log.w(TAG, "display_attach exhausted retries; daemon broker unavailable"); + return; + } + mainHandler.postDelayed(() -> request(attemptsLeft - 1), RETRY_DELAY_MS); + } + + private void onBinderReceived(@NonNull IBinder binder) { + if (attached) { + return; // ignore duplicate broadcasts + } + attached = true; + var svc = INativeDisplayRootService.Stub.asInterface(binder); + service = svc; + try { + binder.linkToDeath(deathRecipient, 0); + } catch (RemoteException e) { + Log.w(TAG, "linkToDeath failed", e); + } + listener.onAttached(svc); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayChromeController.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayChromeController.java new file mode 100644 index 00000000..46f3395c --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayChromeController.java @@ -0,0 +1,153 @@ +// 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.ui.vm.display.base; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Single source of truth for the display chrome: toolbar / status bar / system bars, and which + * parts of the on-screen keyboard are up. Everything that used to flip view visibility directly + * (fullscreen toggle, menu items, keyboard buttons) mutates this state instead, and the one + * {@link Host#applyChrome} callback writes the whole set atomically - so the pieces can never + * drift apart. + * + * Rules: + *
      + *
    • The keyboard mode picks the Main zone: nothing, the one-row companion to the system IME, + * or the laptop keyboard.
    • + *
    • The Extra zone is the point of the system-IME mode - the keys an IME cannot send - so + * it is always up there; only the laptop mode, whose Main block already carries most of + * them, lets it be toggled off. FNx is a toggle in both. Both keep their values through + * {@link KeyboardMode#NONE}: hiding the keyboard is not the same as turning its zones + * off.
    • + *
    • Fullscreen hides toolbar + status bar + system bars + the whole keyboard. The state + * before entering is remembered and restored on exit; changes made while fullscreen show + * on top of it and are not persisted.
    • + *
    • No auto-hide: bar visibility is a pure function of this state. Transient system bars + * swiped in during fullscreen are the system's overlay and don't touch it.
    • + *
    + */ +public final class DisplayChromeController { + public interface Host { + /** + * Write the whole chrome set: toolbar/status bar/system bars shown iff + * {@code !fullscreen}; the keyboard's Main zone per {@code mode}; the Extra and FNx + * zones shown iff their flag is set and {@code mode} shows a keyboard at all. The host + * should re-request insets after applying so the display area updates in one pass. + */ + void applyChrome( + boolean fullscreen, + @NonNull KeyboardMode mode, + boolean extraVisible, + boolean fnxVisible); + } + + /** Persistence hook: fired after a (non-fullscreen) user action changes the state. */ + public interface StateListener { + void onUserStateChanged( + @NonNull KeyboardMode mode, boolean extraVisible, boolean fnxVisible); + } + + private final Host host; + @Nullable + private StateListener stateListener; + private boolean fullscreen; + private KeyboardMode mode; + private boolean extraVisible; + private boolean fnxVisible; + // Pre-fullscreen memory. + private KeyboardMode savedMode; + private boolean savedExtra, savedFnx; + + public DisplayChromeController( + @NonNull KeyboardMode mode, + boolean extraVisible, + boolean fnxVisible, + @NonNull Host host + ) { + this.host = host; + this.mode = mode; + this.extraVisible = extraVisible; + this.fnxVisible = fnxVisible; + } + + public void setStateListener(@Nullable StateListener listener) { + this.stateListener = listener; + } + + /** Push the initial state to the host once its views are ready. */ + public void applyInitial() { + apply(); + } + + public void toggleFullscreen() { + setFullscreen(!fullscreen); + } + + public void setFullscreen(boolean enabled) { + if (fullscreen == enabled) { + return; + } + fullscreen = enabled; + if (enabled) { + savedMode = mode; + savedExtra = extraVisible; + savedFnx = fnxVisible; + mode = KeyboardMode.NONE; + } else { + mode = savedMode == null ? KeyboardMode.NONE : savedMode; + extraVisible = savedExtra; + fnxVisible = savedFnx; + } + apply(); + } + + public void setKeyboardMode(@NonNull KeyboardMode newMode) { + if (mode == newMode) return; + mode = newMode; + notifyState(); + apply(); + } + + public void toggleExtraZone() { + extraVisible = !extraVisible; + notifyState(); + apply(); + } + + public void toggleFnxZone() { + fnxVisible = !fnxVisible; + notifyState(); + apply(); + } + + public boolean isFullscreen() { + return fullscreen; + } + + @NonNull + public KeyboardMode getKeyboardMode() { + return mode; + } + + public boolean isExtraVisible() { + return extraVisible; + } + + public boolean isFnxVisible() { + return fnxVisible; + } + + private void notifyState() { + if (stateListener != null && !fullscreen) + stateListener.onUserStateChanged(mode, extraVisible, fnxVisible); + } + + private void apply() { + boolean showing = mode.showsKeyboard(); + boolean extra = mode == KeyboardMode.SYSTEM || (showing && extraVisible); + host.applyChrome(fullscreen, mode, extra, showing && fnxVisible); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayExtraKeysPanel.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayExtraKeysPanel.java index 6cd01246..18ae8b52 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayExtraKeysPanel.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayExtraKeysPanel.java @@ -1,21 +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.ui.vm.display.base; import static android.view.HapticFeedbackConstants.KEYBOARD_TAP; -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.content.Context; -import android.os.Handler; -import android.os.Looper; +import android.content.res.ColorStateList; import android.util.AttributeSet; import android.view.KeyEvent; import android.view.LayoutInflater; -import android.view.MotionEvent; import android.view.View; -import android.view.ViewGroup; import android.widget.Button; +import android.widget.ImageButton; import android.widget.LinearLayout; import androidx.annotation.NonNull; @@ -23,20 +21,43 @@ import cn.classfun.droidvm.R; +/** + * The keyboard panel's shared zones: Extra (nav cluster and the keys no soft keyboard sends), + * FNx (function keys, shared by both keyboard modes), and the Main row used in system-IME mode. + * The laptop keyboard is {@link DisplayPhysicalKeyboardView}, docked below this and supplying + * its own Main - so whichever mode is up, Extra and FNx look and behave identically. + * + * Non-modifier keys send real key down/up ({@link HoldKeyGroup}: press = down, sliding onto + * another key presses that one, release = up), so the guest sees holds and does its own + * auto-repeat. Shift/Ctrl/Alt/Win are sticky - tap for one-shot, long-press to lock - with + * {@link BaseExtraKeysAdapter} owning that state and this panel rendering it. + */ public final class DisplayExtraKeysPanel extends LinearLayout { - private static final long ANIM_DURATION = 200; - private static final long KEY_REPEAT_DELAY_MS = 400; - private static final long KEY_REPEAT_INTERVAL_MS = 50; - private final Handler handler = new Handler(Looper.getMainLooper()); - private Runnable activeRepeatRunnable; - private LinearLayout fnKeysContainer; - private boolean capsActive = false; - private boolean fnxActive = false; + /** Zone toggle and IME summon live on the keys themselves, not in a menu. */ + public interface ZoneListener { + void onToggleFnxZone(); + + void onShowSystemKeyboard(); + } + + private View extraZone; + private View fnxZone; + private View systemMainRow; @Nullable private KeyListener keyListener; + @Nullable + private ZoneListener zoneListener; + // Notified whenever the modifier toggle state repaints, so a second view of the same state + // (the laptop keyboard's modifier keys) can repaint too. + @Nullable + private Runnable modifierStateObserver; private boolean ctrlDown, altDown, shiftDown, winDown; + private final HoldKeyGroup holdKeys = new HoldKeyGroup((code, down) -> { + if (keyListener != null) keyListener.onKey(code, down); + }); + public DisplayExtraKeysPanel(@NonNull Context context) { super(context); init(context); @@ -47,7 +68,8 @@ public DisplayExtraKeysPanel(@NonNull Context context, @Nullable AttributeSet at init(context); } - public DisplayExtraKeysPanel(@NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + public DisplayExtraKeysPanel( + @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); init(context); } @@ -55,7 +77,9 @@ public DisplayExtraKeysPanel(@NonNull Context context, @Nullable AttributeSet at private void init(@NonNull Context context) { setOrientation(VERTICAL); LayoutInflater.from(context).inflate(R.layout.widget_display_extra_keys, this, true); - fnKeysContainer = findViewById(R.id.fn_keys_container); + extraZone = findViewById(R.id.extra_zone); + fnxZone = findViewById(R.id.fnx_zone); + systemMainRow = findViewById(R.id.system_main_row); setupKeys(); } @@ -63,31 +87,30 @@ public void setKeyListener(@Nullable KeyListener listener) { this.keyListener = listener; } - @SuppressWarnings("unused") + public void setZoneListener(@Nullable ZoneListener listener) { + this.zoneListener = listener; + } + + public void setModifierStateObserver(@Nullable Runnable observer) { + this.modifierStateObserver = observer; + } + public boolean isCtrlDown() { return ctrlDown; } - @SuppressWarnings("unused") public boolean isAltDown() { return altDown; } - @SuppressWarnings("unused") public boolean isShiftDown() { return shiftDown; } - @SuppressWarnings("unused") public boolean isWinDown() { return winDown; } - @SuppressWarnings("unused") - public boolean isCapsActive() { - return capsActive; - } - public void setCtrlDown(boolean v) { ctrlDown = v; updateToggleButtons(); @@ -113,107 +136,94 @@ public void updateToggleButtons() { setToggleStyle(findViewById(R.id.btn_alt), altDown); setToggleStyle(findViewById(R.id.btn_shift), shiftDown); setToggleStyle(findViewById(R.id.btn_win), winDown); - setToggleStyle(findViewById(R.id.btn_cap), capsActive); - setToggleStyle(findViewById(R.id.btn_fnx), fnxActive); + if (modifierStateObserver != null) modifierStateObserver.run(); } - private void setToggleStyle(@Nullable Button btn, boolean active) { - if (btn == null) return; - if (active) { - btn.setBackgroundColor(getContext().getColor(R.color.extra_key_bg_active)); - btn.setTextColor(getContext().getColor(R.color.extra_key_text_active)); - } else { - btn.setBackground(null); - btn.setTextColor(getContext().getColor(R.color.extra_key_text)); - } + /** Repaint the Fn toggle to match the zone actually on screen. */ + public void setZoneToggleState(boolean fnxOn) { + setToggleStyle(findViewById(R.id.btn_zone_fnx), fnxOn); + } + + /** + * Which key owns the Main row's last slot. The IME's own visibility decides: while it is up + * the slot toggles the Fn zone, and while it is dismissed it summons the IME back - the + * other key would do nothing in each case. The Fn zone itself keeps its state throughout; + * only the way to toggle it goes away with the IME. + */ + public void setImeVisible(boolean imeVisible) { + findViewById(R.id.btn_zone_fnx).setVisibility(imeVisible ? VISIBLE : GONE); + findViewById(R.id.btn_show_ime).setVisibility(imeVisible ? GONE : VISIBLE); + } + + /** Works for both kinds of key: a text {@link Button} and an icon-only {@link ImageButton}. */ + private void setToggleStyle(@Nullable View key, boolean active) { + if (key == null) return; + int color = getContext().getColor( + active ? R.color.extra_key_text_active : R.color.extra_key_text); + if (active) key.setBackgroundColor(getContext().getColor(R.color.extra_key_bg_active)); + else key.setBackgroundResource(R.drawable.extra_key_bg); + if (key instanceof Button) ((Button) key).setTextColor(color); + else if (key instanceof ImageButton) + ((ImageButton) key).setImageTintList(ColorStateList.valueOf(color)); } private void setupKeys() { - setKeyRepeat(R.id.btn_esc, () -> fireKeyRepeat(KeyEvent.KEYCODE_ESCAPE)); - setKeyRepeat(R.id.btn_slash, () -> fireCharRepeat('/')); - setKeyRepeat(R.id.btn_dash, () -> fireCharRepeat('-')); - setKeyRepeat(R.id.btn_home, () -> fireKeyRepeat(KeyEvent.KEYCODE_MOVE_HOME)); - setKeyRepeat(R.id.btn_up, () -> fireKeyRepeat(KeyEvent.KEYCODE_DPAD_UP)); - setKeyRepeat(R.id.btn_end, () -> fireKeyRepeat(KeyEvent.KEYCODE_MOVE_END)); - setKeyRepeat(R.id.btn_pgup, () -> fireKeyRepeat(KeyEvent.KEYCODE_PAGE_UP)); - setKeyRepeat(R.id.btn_tab, () -> fireKeyRepeat(KeyEvent.KEYCODE_TAB)); + setupHoldKey(R.id.btn_esc, KeyEvent.KEYCODE_ESCAPE); + setupHoldKey(R.id.btn_slash, KeyEvent.KEYCODE_SLASH); + setupHoldKey(R.id.btn_dash, KeyEvent.KEYCODE_MINUS); + setupHoldKey(R.id.btn_home, KeyEvent.KEYCODE_MOVE_HOME); + setupHoldKey(R.id.btn_up, KeyEvent.KEYCODE_DPAD_UP); + setupHoldKey(R.id.btn_end, KeyEvent.KEYCODE_MOVE_END); + setupHoldKey(R.id.btn_pgup, KeyEvent.KEYCODE_PAGE_UP); + setupHoldKey(R.id.btn_bksp, KeyEvent.KEYCODE_DEL); + setupHoldKey(R.id.btn_del, KeyEvent.KEYCODE_FORWARD_DEL); + setupHoldKey(R.id.btn_ins, KeyEvent.KEYCODE_INSERT); + setupHoldKey(R.id.btn_left, KeyEvent.KEYCODE_DPAD_LEFT); + setupHoldKey(R.id.btn_down, KeyEvent.KEYCODE_DPAD_DOWN); + setupHoldKey(R.id.btn_right, KeyEvent.KEYCODE_DPAD_RIGHT); + setupHoldKey(R.id.btn_pgdn, KeyEvent.KEYCODE_PAGE_DOWN); + setupHoldKey(R.id.btn_f1, KeyEvent.KEYCODE_F1); + setupHoldKey(R.id.btn_f2, KeyEvent.KEYCODE_F2); + setupHoldKey(R.id.btn_f3, KeyEvent.KEYCODE_F3); + setupHoldKey(R.id.btn_f4, KeyEvent.KEYCODE_F4); + setupHoldKey(R.id.btn_f5, KeyEvent.KEYCODE_F5); + setupHoldKey(R.id.btn_f6, KeyEvent.KEYCODE_F6); + setupHoldKey(R.id.btn_prtsc, KeyEvent.KEYCODE_SYSRQ); + setupHoldKey(R.id.btn_f7, KeyEvent.KEYCODE_F7); + setupHoldKey(R.id.btn_f8, KeyEvent.KEYCODE_F8); + setupHoldKey(R.id.btn_f9, KeyEvent.KEYCODE_F9); + setupHoldKey(R.id.btn_f10, KeyEvent.KEYCODE_F10); + setupHoldKey(R.id.btn_f11, KeyEvent.KEYCODE_F11); + setupHoldKey(R.id.btn_f12, KeyEvent.KEYCODE_F12); + setupHoldKey(R.id.btn_pause, KeyEvent.KEYCODE_BREAK); + setupHoldKey(R.id.btn_tab, KeyEvent.KEYCODE_TAB); + setupHoldKey(R.id.btn_enter, KeyEvent.KEYCODE_ENTER); setupModifierKey(R.id.btn_ctrl, KeyEvent.KEYCODE_CTRL_LEFT); - setupModifierKey(R.id.btn_alt, KeyEvent.KEYCODE_ALT_LEFT); - setKeyRepeat(R.id.btn_left, () -> fireKeyRepeat(KeyEvent.KEYCODE_DPAD_LEFT)); - setKeyRepeat(R.id.btn_down, () -> fireKeyRepeat(KeyEvent.KEYCODE_DPAD_DOWN)); - setKeyRepeat(R.id.btn_right, () -> fireKeyRepeat(KeyEvent.KEYCODE_DPAD_RIGHT)); - setKeyRepeat(R.id.btn_pgdn, () -> fireKeyRepeat(KeyEvent.KEYCODE_PAGE_DOWN)); + setupModifierKey(R.id.btn_shift, KeyEvent.KEYCODE_SHIFT_LEFT); setupModifierKey(R.id.btn_win, KeyEvent.KEYCODE_META_LEFT); - setKeyClick(R.id.btn_cap, v -> { - capsActive = !capsActive; - if (keyListener != null) keyListener.onCapsToggle(capsActive); - updateToggleButtons(); + setupModifierKey(R.id.btn_alt, KeyEvent.KEYCODE_ALT_LEFT); + setupTapKey(R.id.btn_zone_fnx, () -> { + if (zoneListener != null) zoneListener.onToggleFnxZone(); }); - setupModifierKey(R.id.btn_shift, KeyEvent.KEYCODE_SHIFT_LEFT); - setKeyRepeat(R.id.btn_del, () -> fireKeyRepeat(KeyEvent.KEYCODE_FORWARD_DEL)); - setKeyRepeat(R.id.btn_ins, () -> fireKeyRepeat(KeyEvent.KEYCODE_INSERT)); - setKeyRepeat(R.id.btn_enter, () -> fireKeyRepeat(KeyEvent.KEYCODE_ENTER)); - setKeyClick(R.id.btn_fnx, v -> { - fnxActive = !fnxActive; - setRowVisible(fnKeysContainer, fnxActive); - updateToggleButtons(); + setupTapKey(R.id.btn_show_ime, () -> { + if (zoneListener != null) zoneListener.onShowSystemKeyboard(); }); - setKeyRepeat(R.id.btn_f1, () -> fireKeyRepeat(KeyEvent.KEYCODE_F1)); - setKeyRepeat(R.id.btn_f2, () -> fireKeyRepeat(KeyEvent.KEYCODE_F2)); - setKeyRepeat(R.id.btn_f3, () -> fireKeyRepeat(KeyEvent.KEYCODE_F3)); - setKeyRepeat(R.id.btn_f4, () -> fireKeyRepeat(KeyEvent.KEYCODE_F4)); - setKeyRepeat(R.id.btn_f5, () -> fireKeyRepeat(KeyEvent.KEYCODE_F5)); - setKeyRepeat(R.id.btn_f6, () -> fireKeyRepeat(KeyEvent.KEYCODE_F6)); - setKeyRepeat(R.id.btn_f7, () -> fireKeyRepeat(KeyEvent.KEYCODE_F7)); - setKeyRepeat(R.id.btn_f8, () -> fireKeyRepeat(KeyEvent.KEYCODE_F8)); - setKeyRepeat(R.id.btn_f9, () -> fireKeyRepeat(KeyEvent.KEYCODE_F9)); - setKeyRepeat(R.id.btn_f10, () -> fireKeyRepeat(KeyEvent.KEYCODE_F10)); - setKeyRepeat(R.id.btn_f11, () -> fireKeyRepeat(KeyEvent.KEYCODE_F11)); - setKeyRepeat(R.id.btn_f12, () -> fireKeyRepeat(KeyEvent.KEYCODE_F12)); } - private void fireKeyRepeat(int keyCode) { - if (keyListener != null) keyListener.onKeyRepeat(keyCode); + private void setupHoldKey(int id, int keyCode) { + holdKeys.register(findViewById(id), keyCode); } - private void fireCharRepeat(char ch) { - if (keyListener != null) keyListener.onCharRepeat(ch); - } - - private void setKeyClick(int id, OnClickListener listener) { + private void setupTapKey(int id, @NonNull Runnable action) { findViewById(id).setOnClickListener(v -> { v.performHapticFeedback(KEYBOARD_TAP); - listener.onClick(v); + action.run(); }); } @SuppressLint("ClickableViewAccessibility") - private void setKeyRepeat(int id, Runnable action) { - findViewById(id).setOnTouchListener((v, event) -> { - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - v.setPressed(true); - v.performHapticFeedback(KEYBOARD_TAP); - action.run(); - stopKeyRepeat(); - activeRepeatRunnable = () -> { - action.run(); - handler.postDelayed(activeRepeatRunnable, KEY_REPEAT_INTERVAL_MS); - }; - handler.postDelayed(activeRepeatRunnable, KEY_REPEAT_DELAY_MS); - return true; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - v.setPressed(false); - stopKeyRepeat(); - return true; - } - return false; - }); - } - private void setupModifierKey(int btnId, int keyCode) { - View btn = findViewById(btnId); + var btn = findViewById(btnId); btn.setOnClickListener(v -> { v.performHapticFeedback(KEYBOARD_TAP); if (keyListener != null) keyListener.onModifierClick(keyCode); @@ -225,77 +235,30 @@ private void setupModifierKey(int btnId, int keyCode) { }); } - public void stopKeyRepeat() { - if (activeRepeatRunnable != null) { - handler.removeCallbacks(activeRepeatRunnable); - activeRepeatRunnable = null; + /** + * Show the zones the chrome state calls for. The panel itself is visible whenever any zone + * is - an empty panel would otherwise leave a blank strip over the display. + */ + public void applyZones(boolean extraOn, boolean fnxOn, boolean systemMainOn) { + setZoneToggleState(fnxOn); + boolean any = extraOn || fnxOn || systemMainOn; + if (!any) { + // Slide the whole panel away; the zones keep their own states for the way back. + ViewHeightAnimator.hide(this); + return; } - } - - public void animateIn() { - animateRowIn(this); - } - - public void animateOut() { - animateRowOut(this); - } - - public void setVisibleAnimated(boolean visible) { - if (visible) animateIn(); - else animateOut(); - } - - private void animateRowIn(@NonNull View row) { - if (row.getVisibility() == VISIBLE) return; - row.setVisibility(VISIBLE); - row.measure( - MeasureSpec.makeMeasureSpec( - ((View) row.getParent()).getWidth(), MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED) - ); - int target = row.getMeasuredHeight(); - var lp = row.getLayoutParams(); - lp.height = 0; - row.requestLayout(); - var anim = ValueAnimator.ofInt(0, target); - anim.setDuration(ANIM_DURATION); - anim.addUpdateListener(a -> { - lp.height = (int) a.getAnimatedValue(); - row.requestLayout(); - }); - anim.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator a) { - lp.height = ViewGroup.LayoutParams.WRAP_CONTENT; - row.requestLayout(); - } - }); - anim.start(); - } - - private void animateRowOut(@NonNull View row) { - if (row.getVisibility() == GONE) return; - int start = row.getHeight(); - var lp = row.getLayoutParams(); - var anim = ValueAnimator.ofInt(start, 0); - anim.setDuration(ANIM_DURATION); - anim.addUpdateListener(a -> { - lp.height = (int) a.getAnimatedValue(); - row.requestLayout(); - }); - anim.addListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationEnd(Animator a) { - row.setVisibility(GONE); - lp.height = ViewGroup.LayoutParams.WRAP_CONTENT; - row.requestLayout(); - } - }); - anim.start(); - } - - private void setRowVisible(@NonNull View row, boolean visible) { - if (visible) animateRowIn(row); - else animateRowOut(row); + if (getVisibility() != VISIBLE) { + // Panel arriving: put the zones in place first, then animate the panel as a whole, + // so its slide-in is one movement rather than several nested ones. + extraZone.setVisibility(extraOn ? VISIBLE : GONE); + fnxZone.setVisibility(fnxOn ? VISIBLE : GONE); + systemMainRow.setVisibility(systemMainOn ? VISIBLE : GONE); + ViewHeightAnimator.show(this); + return; + } + // Panel already up: animate each zone so Ex/FNx slide open and shut. + ViewHeightAnimator.setVisible(extraZone, extraOn); + ViewHeightAnimator.setVisible(fnxZone, fnxOn); + ViewHeightAnimator.setVisible(systemMainRow, systemMainOn); } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayInputBackend.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayInputBackend.java new file mode 100644 index 00000000..a796c4cb --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayInputBackend.java @@ -0,0 +1,38 @@ +// 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.ui.vm.display.base; + +import androidx.annotation.NonNull; + +/** + * The seam between the shared display control bar (system keyboard, extra keys, input-mode selector, + * fullscreen, rotate) and a concrete display backend. Both backends implement this so the control + * bar is backend-agnostic: + * + *
      + *
    • VNC -- maps to {@code VncClient}: Android key codes to X11 keysyms, pointer to RFB events.
    • + *
    • Native -- maps to {@code InputForwarder}/{@code EvdevEncoder}: key codes to Linux evdev + * scan codes, pointer to the mode-appropriate virtio-input device.
    • + *
    + * + * Only event delivery lives here. Fullscreen and screen rotation are window-level concerns the + * control bar drives on the host Activity directly, not through the backend. + */ +public interface DisplayInputBackend { + /** Sends a key by Android key code; the backend translates to its wire representation. */ + void sendKey(int androidKeyCode, boolean down); + + /** + * Sends a printable character, synthesizing whatever the guest needs for it (e.g. Shift-wrapping + * uppercase and shifted symbols on the evdev backend). Used by the soft keyboard's commit path. + */ + void sendChar(char c); + + /** + * The pointer input mode changed. The backend adjusts how it turns subsequent on-screen touches + * into guest input (multi-touch, relative mouse, or absolute single-pointer tablet). Backends + * that route to distinct guest input devices switch the target device here. + */ + void setInputMode(@NonNull InputMode mode); +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayKeyboardMenuRow.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayKeyboardMenuRow.java new file mode 100644 index 00000000..a25b953d --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayKeyboardMenuRow.java @@ -0,0 +1,61 @@ +// 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.ui.vm.display.base; + +import android.view.LayoutInflater; +import android.view.View; + +import androidx.annotation.NonNull; + +import com.google.android.material.button.MaterialButtonToggleGroup; + +import java.util.function.Consumer; + +import cn.classfun.droidvm.R; + +/** + * The keyboard row of the display fab-menu header: pick the typing surface - none, the system + * IME plus its companion row, or the laptop keyboard. Only the surface is chosen here; the + * Extra and FNx zones are toggled from keys on the keyboard itself, where they are visible. + */ +public final class DisplayKeyboardMenuRow { + private DisplayKeyboardMenuRow() { + } + + @NonNull + public static View build( + @NonNull LayoutInflater inflater, + @NonNull KeyboardMode current, + @NonNull Consumer onPick, + @NonNull Runnable dismiss + ) { + var group = (MaterialButtonToggleGroup) + inflater.inflate(R.layout.view_keyboard_mode_toggle, null); + group.check(buttonFor(current)); + group.addOnButtonCheckedListener((g, checkedId, isChecked) -> { + if (!isChecked) return; + onPick.accept(modeFor(checkedId)); + dismiss.run(); + }); + return group; + } + + private static int buttonFor(@NonNull KeyboardMode mode) { + switch (mode) { + case NONE: + return R.id.mode_kb_none; + case LAPTOP: + return R.id.mode_kb_laptop; + default: + return R.id.mode_kb_system; + } + } + + @NonNull + private static KeyboardMode modeFor(int buttonId) { + if (buttonId == R.id.mode_kb_none) return KeyboardMode.NONE; + if (buttonId == R.id.mode_kb_laptop) return KeyboardMode.LAPTOP; + return KeyboardMode.SYSTEM; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayPhysicalKeyboardView.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayPhysicalKeyboardView.java new file mode 100644 index 00000000..205b5454 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayPhysicalKeyboardView.java @@ -0,0 +1,330 @@ +// 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.ui.vm.display.base; + +import static android.view.HapticFeedbackConstants.KEYBOARD_TAP; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.KeyEvent; +import android.view.View; +import android.widget.Button; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.LinearLayout; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import cn.classfun.droidvm.R; + +/** + * The laptop keyboard: the Main zone of {@link KeyboardMode#LAPTOP}. Only the ANSI main block + * lives here - the function keys are the panel's shared FNx zone and the navigation cluster its + * Extra zone, so both modes show the same rows above whichever Main is up. The bottom row ends + * with the zone toggles and a button that puts the whole keyboard away. + * + * Non-modifier keys send real key down/up ({@link HoldKeyGroup}: press = down, sliding onto + * another key presses that one, release = up), so holds (WASD movement, held arrows) reach the + * guest and several keys can be held at once. Shift/Ctrl/Alt/Win are sticky, sharing their state + * with the panel through the adapter; {@link #refreshModifiers} repaints it here. + */ +public final class DisplayPhysicalKeyboardView extends LinearLayout { + /** The keys that act on the keyboard itself rather than sending anything to the guest. */ + public interface ZoneListener { + void onToggleExtraZone(); + + void onToggleFnxZone(); + + void onCloseKeyboard(); + } + + private static final int ROW_HEIGHT_DP = 40; + private static final float TEXT_SIZE_SP = 10f; + + private static final int KIND_NORMAL = 0; + private static final int KIND_MODIFIER = 1; + private static final int KIND_ZONE_EXTRA = 2; + private static final int KIND_ZONE_FNX = 3; + private static final int KIND_CLOSE = 4; + + private static final class Key { + final String label; + /** What the key reads while Shift is held; same as {@link #label} when it doesn't shift. */ + final String shiftLabel; + final int code; + final float weight; + final int kind; + final @DrawableRes int icon; + + Key(String label, String shiftLabel, int code, float weight, int kind, + @DrawableRes int icon) { + this.label = label; + this.shiftLabel = shiftLabel; + this.code = code; + this.weight = weight; + this.kind = kind; + this.icon = icon; + } + } + + /** Letter: lower case unshifted, upper case shifted, like the key it stands for. */ + private static Key a(String lower, int code) { + return new Key(lower, lower.toUpperCase(Locale.ROOT), code, 1f, KIND_NORMAL, 0); + } + + /** Key whose face changes under Shift (number row, punctuation). */ + private static Key k(String label, String shiftLabel, int code) { + return k(label, shiftLabel, code, 1f); + } + + private static Key k(String label, String shiftLabel, int code, float weight) { + return new Key(label, shiftLabel, code, weight, KIND_NORMAL, 0); + } + + private static Key k(String label, int code, float weight) { + return new Key(label, label, code, weight, KIND_NORMAL, 0); + } + + /** Key drawn as an icon; glyphs like tab and return come out thin and tiny in the font. */ + private static Key kIcon(@DrawableRes int icon, int code, float weight) { + return new Key("", "", code, weight, KIND_NORMAL, icon); + } + + private static Key m(String label, int code, float weight) { + return new Key(label, label, code, weight, KIND_MODIFIER, 0); + } + + private static Key mIcon(@DrawableRes int icon, int code, float weight) { + return new Key("", "", code, weight, KIND_MODIFIER, icon); + } + + private static Key special(String label, int kind, float weight, @DrawableRes int icon) { + return new Key(label, label, 0, weight, kind, icon); + } + + // Every row totals 15 weight units, so the columns line up down the block. + private static final Key[][] ROWS = { + { + k("`", "~", KeyEvent.KEYCODE_GRAVE), + k("1", "!", KeyEvent.KEYCODE_1), k("2", "@", KeyEvent.KEYCODE_2), + k("3", "#", KeyEvent.KEYCODE_3), k("4", "$", KeyEvent.KEYCODE_4), + k("5", "%", KeyEvent.KEYCODE_5), k("6", "^", KeyEvent.KEYCODE_6), + k("7", "&", KeyEvent.KEYCODE_7), k("8", "*", KeyEvent.KEYCODE_8), + k("9", "(", KeyEvent.KEYCODE_9), k("0", ")", KeyEvent.KEYCODE_0), + k("-", "_", KeyEvent.KEYCODE_MINUS), k("=", "+", KeyEvent.KEYCODE_EQUALS), + kIcon(R.drawable.ic_key_backspace, KeyEvent.KEYCODE_DEL, 2f), + }, + { + kIcon(R.drawable.ic_key_tab, KeyEvent.KEYCODE_TAB, 1.5f), + a("q", KeyEvent.KEYCODE_Q), a("w", KeyEvent.KEYCODE_W), + a("e", KeyEvent.KEYCODE_E), a("r", KeyEvent.KEYCODE_R), + a("t", KeyEvent.KEYCODE_T), a("y", KeyEvent.KEYCODE_Y), + a("u", KeyEvent.KEYCODE_U), a("i", KeyEvent.KEYCODE_I), + a("o", KeyEvent.KEYCODE_O), a("p", KeyEvent.KEYCODE_P), + k("[", "{", KeyEvent.KEYCODE_LEFT_BRACKET), + k("]", "}", KeyEvent.KEYCODE_RIGHT_BRACKET), + k("\\", "|", KeyEvent.KEYCODE_BACKSLASH, 1.5f), + }, + { + k("CAPS", KeyEvent.KEYCODE_CAPS_LOCK, 1.75f), + a("a", KeyEvent.KEYCODE_A), a("s", KeyEvent.KEYCODE_S), + a("d", KeyEvent.KEYCODE_D), a("f", KeyEvent.KEYCODE_F), + a("g", KeyEvent.KEYCODE_G), a("h", KeyEvent.KEYCODE_H), + a("j", KeyEvent.KEYCODE_J), a("k", KeyEvent.KEYCODE_K), + a("l", KeyEvent.KEYCODE_L), + k(";", ":", KeyEvent.KEYCODE_SEMICOLON), + k("'", "\"", KeyEvent.KEYCODE_APOSTROPHE), + kIcon(R.drawable.ic_key_enter, KeyEvent.KEYCODE_ENTER, 2.25f), + }, + { + mIcon(R.drawable.ic_key_shift, KeyEvent.KEYCODE_SHIFT_LEFT, 2.25f), + a("z", KeyEvent.KEYCODE_Z), a("x", KeyEvent.KEYCODE_X), + a("c", KeyEvent.KEYCODE_C), a("v", KeyEvent.KEYCODE_V), + a("b", KeyEvent.KEYCODE_B), a("n", KeyEvent.KEYCODE_N), + a("m", KeyEvent.KEYCODE_M), + k(",", "<", KeyEvent.KEYCODE_COMMA), k(".", ">", KeyEvent.KEYCODE_PERIOD), + k("/", "?", KeyEvent.KEYCODE_SLASH), + mIcon(R.drawable.ic_key_shift, KeyEvent.KEYCODE_SHIFT_LEFT, 2.75f), + }, + { + m("CTRL", KeyEvent.KEYCODE_CTRL_LEFT, 1.5f), + mIcon(R.drawable.ic_key_windows, KeyEvent.KEYCODE_META_LEFT, 1.5f), + m("ALT", KeyEvent.KEYCODE_ALT_LEFT, 1.5f), + kIcon(R.drawable.ic_key_space, KeyEvent.KEYCODE_SPACE, 4.5f), + m("ALT", KeyEvent.KEYCODE_ALT_LEFT, 1.5f), + m("CTRL", KeyEvent.KEYCODE_CTRL_LEFT, 1.5f), + special("Ex", KIND_ZONE_EXTRA, 1f, 0), + special("Fn", KIND_ZONE_FNX, 1f, 0), + special("", KIND_CLOSE, 1f, R.drawable.ic_keyboard_close), + }, + }; + + @Nullable + private KeyListener keyListener; + @Nullable + private ZoneListener zoneListener; + private final Map> modifierButtons = new HashMap<>(); + private final List extraZoneButtons = new ArrayList<>(); + private final List fnxZoneButtons = new ArrayList<>(); + // Keys whose face changes under Shift, so the keyboard reads like what it will type. + private final Map shiftableKeys = new HashMap<>(); + private final HoldKeyGroup holdKeys = new HoldKeyGroup((code, down) -> { + if (keyListener != null) keyListener.onKey(code, down); + }); + + public DisplayPhysicalKeyboardView(@NonNull Context context) { + super(context); + init(context); + } + + public DisplayPhysicalKeyboardView(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + init(context); + } + + public DisplayPhysicalKeyboardView( + @NonNull Context context, @Nullable AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context); + } + + private void init(@NonNull Context context) { + setOrientation(VERTICAL); + for (Key[] rowSpec : ROWS) addView(buildRow(context, rowSpec)); + } + + @NonNull + private LinearLayout buildRow(@NonNull Context context, @NonNull Key[] rowSpec) { + int rowHeight = Math.round( + ROW_HEIGHT_DP * context.getResources().getDisplayMetrics().density); + var row = new LinearLayout(context); + row.setOrientation(HORIZONTAL); + row.setBackgroundColor(context.getColor(R.color.extra_keys_background)); + for (Key key : rowSpec) row.addView(buildKey(context, key)); + row.setLayoutParams(new LayoutParams(LayoutParams.MATCH_PARENT, rowHeight)); + return row; + } + + @NonNull + private View buildKey(@NonNull Context context, @NonNull Key key) { + View view; + if (key.icon != 0) { + // An ImageButton centres its drawable; a Button's compound drawable does not, and at + // these key sizes the offset is obvious. + var img = new ImageButton(context); + img.setImageResource(key.icon); + img.setScaleType(ImageView.ScaleType.CENTER); + img.setBackgroundResource(R.drawable.extra_key_bg); + img.setImageTintList(ColorStateList.valueOf( + context.getColor(R.color.extra_key_text))); + img.setPadding(0, 0, 0, 0); + view = img; + } else { + // Same visual as the extra-keys strip; denser text since these rows fit up to 14 keys. + var btn = new Button(context, null, 0, R.style.ExtraKey); + btn.setText(key.label); + btn.setTextSize(TypedValue.COMPLEX_UNIT_SP, TEXT_SIZE_SP); + if (!key.label.equals(key.shiftLabel)) shiftableKeys.put(btn, key); + view = btn; + } + view.setLayoutParams(new LayoutParams(0, LayoutParams.MATCH_PARENT, key.weight)); + switch (key.kind) { + case KIND_MODIFIER: + modifierButtons.computeIfAbsent(key.code, c -> new ArrayList<>()).add(view); + view.setOnClickListener(v -> { + v.performHapticFeedback(KEYBOARD_TAP); + if (keyListener != null) keyListener.onModifierClick(key.code); + }); + view.setOnLongClickListener(v -> { + v.performHapticFeedback(KEYBOARD_TAP); + if (keyListener != null) keyListener.onModifierLongClick(key.code); + return true; + }); + break; + case KIND_ZONE_EXTRA: + extraZoneButtons.add(view); + setTapAction(view, () -> { + if (zoneListener != null) zoneListener.onToggleExtraZone(); + }); + break; + case KIND_ZONE_FNX: + fnxZoneButtons.add(view); + setTapAction(view, () -> { + if (zoneListener != null) zoneListener.onToggleFnxZone(); + }); + break; + case KIND_CLOSE: + setTapAction(view, () -> { + if (zoneListener != null) zoneListener.onCloseKeyboard(); + }); + break; + default: + holdKeys.register(view, key.code); + break; + } + return view; + } + + private void setTapAction(@NonNull View btn, @NonNull Runnable action) { + btn.setOnClickListener(v -> { + v.performHapticFeedback(KEYBOARD_TAP); + action.run(); + }); + } + + public void setKeyListener(@Nullable KeyListener listener) { + this.keyListener = listener; + } + + public void setZoneListener(@Nullable ZoneListener listener) { + this.zoneListener = listener; + } + + /** Repaint the sticky-modifier keys, and the key faces Shift changes, from adapter state. */ + public void refreshModifiers(boolean ctrl, boolean alt, boolean shift, boolean win) { + paintModifier(KeyEvent.KEYCODE_CTRL_LEFT, ctrl); + paintModifier(KeyEvent.KEYCODE_ALT_LEFT, alt); + paintModifier(KeyEvent.KEYCODE_SHIFT_LEFT, shift); + paintModifier(KeyEvent.KEYCODE_META_LEFT, win); + for (var entry : shiftableKeys.entrySet()) + entry.getKey().setText(shift ? entry.getValue().shiftLabel : entry.getValue().label); + } + + /** Repaint the Extra/FNx toggles to match the zones actually on screen. */ + public void setZoneToggleState(boolean extraOn, boolean fnxOn) { + for (var btn : extraZoneButtons) paintToggle(btn, extraOn); + for (var btn : fnxZoneButtons) paintToggle(btn, fnxOn); + } + + private void paintModifier(int keyCode, boolean active) { + var buttons = modifierButtons.get(keyCode); + if (buttons == null) return; + for (var btn : buttons) paintToggle(btn, active); + } + + /** Works for both kinds of key: a text {@link Button} and an icon-only {@link ImageButton}. */ + private void paintToggle(@NonNull View key, boolean active) { + int color = getContext().getColor( + active ? R.color.extra_key_text_active : R.color.extra_key_text); + if (active) key.setBackgroundColor(getContext().getColor(R.color.extra_key_bg_active)); + else key.setBackgroundResource(R.drawable.extra_key_bg); + if (key instanceof Button) ((Button) key).setTextColor(color); + else if (key instanceof ImageButton) + ((ImageButton) key).setImageTintList(ColorStateList.valueOf(color)); + } + + public void setVisibleAnimated(boolean visible) { + ViewHeightAnimator.setVisible(this, visible); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayPresentation.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayPresentation.java index 434f2847..90839757 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayPresentation.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayPresentation.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.ui.vm.display.base; import android.app.Presentation; @@ -6,10 +9,14 @@ import android.hardware.display.DisplayManager; import android.os.Bundle; import android.view.Display; +import android.view.Gravity; +import android.view.TextureView; +import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.google.android.material.dialog.MaterialAlertDialogBuilder; @@ -19,6 +26,11 @@ public final class DisplayPresentation extends Presentation { private ImageView ivDisplay; + private FrameLayout root; + private TextureView h264View; + /** The stream's size, kept so the fit can be redone when the window's own size changes. */ + private int streamWidth; + private int streamHeight; private static final String DISPLAY_CATEGORY_ALL_INCLUDING_DISABLED = "android.hardware.display.category.ALL_INCLUDING_DISABLED"; @@ -31,6 +43,54 @@ protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.presentation_display); ivDisplay = findViewById(R.id.iv_presentation_display); + root = findViewById(R.id.presentation_root); + h264View = findViewById(R.id.texture_h264); + // The external display can change size under a running stream -- a mode change, a + // projection that resizes -- and the decoder view is sized in pixels rather than by a + // scale type, so the fit has to be redone rather than merely re-measured. + root.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or2, ob) -> applyH264Fit()); + } + + /** + * The view the H.264 decoder draws into on this display, or null before the window is built. + * + *

    It belongs to this window and not to the console activity, which is the whole difference + * between this path and the phone console's: the picture is on another display, so the decoder + * has to be pointed at a Surface that is also on it.

    + */ + @Nullable + public TextureView getH264View() { + return h264View; + } + + /** + * Letterboxes the decoder view to a stream of [width]x[height], or clears the fit with zeroes. + * + *

    This is what {@code fitCenter} does for the RFB {@link ImageView} beside it, done by hand + * because a {@link TextureView} has no scale type: it stretches its Surface to whatever bounds + * it is given. Left alone at {@code match_parent} it would show the guest's screen distorted on + * any display whose aspect differs from the guest's -- and the fallback to the ImageView + * underneath would then visibly change shape, which is the one thing the two views showing the + * same picture are supposed to make impossible.

    + */ + public void fitH264(int width, int height) { + streamWidth = width; + streamHeight = height; + applyH264Fit(); + } + + private void applyH264Fit() { + if (h264View == null || root == null) return; + if (streamWidth <= 0 || streamHeight <= 0) return; + var areaW = root.getWidth(); + var areaH = root.getHeight(); + if (areaW <= 0 || areaH <= 0) return; + var scale = Math.min(areaW / (float) streamWidth, areaH / (float) streamHeight); + var fitW = Math.max(1, Math.round(streamWidth * scale)); + var fitH = Math.max(1, Math.round(streamHeight * scale)); + var lp = h264View.getLayoutParams(); + if (lp.width == fitW && lp.height == fitH) return; + h264View.setLayoutParams(new FrameLayout.LayoutParams(fitW, fitH, Gravity.CENTER)); } public void updateBitmap(@NonNull Bitmap bitmap) { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplaySource.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplaySource.java new file mode 100644 index 00000000..ef4adfe2 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplaySource.java @@ -0,0 +1,68 @@ +// 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.ui.vm.display.base; + +import androidx.annotation.NonNull; + +/** + * Pluggable VM display source: the thing that puts guest pixels on screen. The display activities + * only talk to this interface plus {@link DisplayViewportController}/{@link + * DisplayChromeController}, so a new source (e.g. a zero-copy AHardwareBuffer shared straight + * into the guest so it draws without a CPU copy) slots in without touching the console logic. + * + * Implementations today: {@code NativeSurfaceSource} (crosvm renders into an Android Surface via + * the per-VM ICrosvmAndroidDisplayService) and {@code VncBitmapSource} (RFB framebuffer copies + * into a bitmap-backed view). + */ +public interface DisplaySource { + enum State { + CONNECTING, + CONNECTED, + ERROR + } + + interface Callbacks { + /** + * Guest/framebuffer resolution known or changed - feed + * {@link DisplayViewportController#setContentSize}. In auto-resize mode this is also the + * ack of a {@link #requestGuestResize} request. + */ + void onContentSize(int width, int height); + + /** Connection-level state for the status bar / connecting overlay. */ + void onStateChanged(@NonNull State state); + } + + /** Start producing frames. Idempotent; sources that connect on their own may no-op. */ + void start(); + + /** Stop producing frames and release resources; the source is not reusable afterwards. */ + void shutdown(); + + /** + * Whether the guest display resolution can be changed at runtime (Auto-resize Guest + * Display). Sources without a resize channel return false and {@link #requestGuestResize} + * is a no-op; {@link DisplayViewportController#setAutoResize} must not be enabled for them. + */ + boolean supportsGuestResize(); + + /** Ask the guest display to switch to width x height; the ack arrives via onContentSize. */ + void requestGuestResize(int width, int height); + + /** + * Give this source a view to host the guest's HARDWARE cursor, or null for none. + * + * Only meaningful where the transport carries the cursor plane separately from the scanout + * (the native crosvm path does; VNC composites it server-side), so the default ignores it. + */ + default void setCursorView(android.view.SurfaceView cursorView) { + } + + /** + * Observe the guest's cursor position, in GUEST framebuffer coordinates, on the main thread. + * Default: no such channel, never called. + */ + default void setCursorListener(java.util.function.BiConsumer listener) { + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayTouchPadPanel.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayTouchPadPanel.java index 5f8233bd..30526ae6 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayTouchPadPanel.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayTouchPadPanel.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.ui.vm.display.base; import static android.view.HapticFeedbackConstants.KEYBOARD_TAP; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayViewportController.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayViewportController.java new file mode 100644 index 00000000..9f0a0431 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/DisplayViewportController.java @@ -0,0 +1,262 @@ +// 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.ui.vm.display.base; + +import androidx.annotation.NonNull; + +/** + * Single source of truth for the VM display viewport: where the guest image sits inside the + * display area, at what scale, under every combination of chrome (toolbar/status bar/extra + * keys/IME/system bars) visibility. Pure math, no Android view types, so the rules are unit + * testable; the owning activity feeds it sizes and gestures and applies the emitted geometry. + * + * State model: {@code scale} is absolute (screen px per content px) and {@code offset} is the + * content center relative to the display-area center. The invariant rules: + * + *
      + *
    • Fitted (scale at the letterbox fit): any display-area change re-fits automatically, so + * a "fit to window" view always stays fit-to-window.
    • + *
    • Zoomed: a display-area change keeps the absolute scale and the center offset - the image + * does not change size on screen, its center just follows the area center - then the offset + * is clamped back into the pan bounds. If the area grew enough that the fit scale catches + * up with the current scale, it snaps back to fitted.
    • + *
    • Degenerate area (e.g. landscape with a tall IME squeezing the container below + * {@code minAreaPx}, possibly to zero or negative): freeze. No geometry is recomputed or + * emitted, everything holds its last good position until the area recovers.
    • + *
    • Auto-resize mode (guest display follows the area): zoom gestures are disabled, every + * accepted area change re-fits and asks the listener to resize the guest. Debouncing the + * guest resize request is the caller's job.
    • + *
    + * + * The emitted geometry maps onto the existing view mechanics: lay the content view out at + * {@code baseW x baseH} centered in the container (the letterbox fit), then apply + * {@code viewScale} and translate by {@code offset}. + */ +public final class DisplayViewportController { + /** Maximum zoom, relative to the letterbox fit scale. */ + public static final float MAX_ZOOM = 5f; + // A scale within 0.1% of the fit scale counts as fitted (float noise + snap-to-fit). + private static final float FIT_SNAP = 1.001f; + + public interface Listener { + /** + * Apply the viewport: content view laid out at baseW x baseH centered in the area, then + * scaled by viewScale (1 = fitted) and translated by offset px. + */ + void onViewportChanged(int baseW, int baseH, float viewScale, float offsetX, float offsetY); + + /** Auto-resize mode only: the area changed; ask the guest display to match it. */ + void onGuestResizeWanted(int areaW, int areaH); + } + + private final int minAreaPx; + private final Listener listener; + + private int contentW, contentH; // guest/framebuffer resolution + private int areaW, areaH; // last accepted (non-degenerate) display area + private float scale; // absolute: screen px per content px + private float offsetX, offsetY; // content center relative to area center, screen px + private boolean autoResize; + private boolean frozen; + + /** + * @param minAreaPx area dimensions below this freeze the viewport instead of re-laying out. + */ + public DisplayViewportController(int minAreaPx, @NonNull Listener listener) { + this.minAreaPx = Math.max(1, minAreaPx); + this.listener = listener; + } + + /** Guest/framebuffer resolution known or changed. Always re-fits (a resolution change is a + * content-level event; in auto-resize mode it is the ack of a resize request). */ + public void setContentSize(int w, int h) { + if (w <= 0 || h <= 0 || (w == contentW && h == contentH)) { + return; + } + contentW = w; + contentH = h; + if (areaW <= 0 || areaH <= 0) { + // No display area yet (content size can arrive before the first layout pass, e.g. + // from the launch intent in onCreate): fitScale() would be 0 and the emitted + // viewScale NaN. Hold; the first setArea() re-fits and emits. + return; + } + fit(); + emit(); + } + + /** Display-area (container) size changed. This is the single entry point for every chrome/IME/ + * rotation-driven layout change; the caller does not need to know what caused it. */ + public void setArea(int w, int h) { + if (w < minAreaPx || h < minAreaPx) { + // Degenerate (possibly zero/negative under an oversized IME): hold everything. + frozen = true; + return; + } + frozen = false; + if (w == areaW && h == areaH) { + return; // no-op; layout listeners re-fire liberally + } + boolean hadArea = areaW > 0 && areaH > 0; + boolean wasFitted = hadArea && ready() && scale <= fitScale() * FIT_SNAP; + areaW = w; + areaH = h; + if (!ready()) { + return; + } + if (autoResize) { + fit(); + emit(); + listener.onGuestResizeWanted(w, h); + return; + } + if (!hadArea || wasFitted || scale <= fitScale() * FIT_SNAP) { + // Was fit-to-window, or the area grew past the current zoom: back to fit. + fit(); + } else { + // Zoomed: same on-screen size, same center offset, clamped into the new bounds. + clampOffset(); + } + emit(); + } + + /** + * Three-finger zoom/pan gesture step. {@code scaleFactor} is relative to the current scale; + * dx/dy are pan deltas in screen px. Ignored while frozen or in auto-resize mode. + */ + public void onZoomPan(float scaleFactor, float dxPx, float dyPx) { + if (!ready() || areaW == 0 || frozen || autoResize) { + return; + } + float fit = fitScale(); + float next = clamp(scale * scaleFactor, fit, fit * MAX_ZOOM); + if (next <= fit * FIT_SNAP) { + fit(); // snap back to fit; a fitted view has nothing to pan + } else { + scale = next; + offsetX += dxPx; + offsetY += dyPx; + clampOffset(); + } + emit(); + } + + /** + * Pan the smallest amount that brings a GUEST-space point back inside the visible area, with + * {@code marginPx} of screen-space breathing room around it. No-op unless zoomed in. + * + * This is what makes the view follow the guest's pointer while zoomed: the cursor can leave + * the visible rectangle without the user ever touching the edge of the screen, because in + * relative-pointer mode a small finger drag can move the guest cursor a long way. + * + * Deliberately the SMALLEST correction rather than re-centring on the point: re-centring turns + * every cursor movement near an edge into a large view jump, which is far more disorienting + * than the pointer briefly reaching the edge. When the view is fitted there is nothing + * off-screen to reveal, and clampOffset would undo any pan anyway, so it returns early. + * + * @return true if the viewport actually moved. + */ + public boolean panToShowContentPoint(float gx, float gy, float marginPx) { + if (!ready() || areaW == 0 || frozen || autoResize || isFitted()) { + return false; + } + // Content is drawn centred on the area, then displaced by offset. + float sx = areaW / 2f + offsetX + (gx - contentW / 2f) * scale; + float sy = areaH / 2f + offsetY + (gy - contentH / 2f) * scale; + + // A margin wider than half the area would fight itself on both edges at once. + float mx = Math.min(marginPx, areaW / 2f); + float my = Math.min(marginPx, areaH / 2f); + + float beforeX = offsetX, beforeY = offsetY; + if (sx < mx) { + offsetX += mx - sx; + } else if (sx > areaW - mx) { + offsetX -= sx - (areaW - mx); + } + if (sy < my) { + offsetY += my - sy; + } else if (sy > areaH - my) { + offsetY -= sy - (areaH - my); + } + if (offsetX == beforeX && offsetY == beforeY) { + return false; + } + clampOffset(); + if (offsetX == beforeX && offsetY == beforeY) { + return false; // the pan was entirely clamped away; do not emit a no-change frame + } + emit(); + return true; + } + + /** Back to fit-to-window (e.g. on input-mode switch). */ + public void resetToFit() { + if (!ready() || areaW == 0) { + return; + } + fit(); + emit(); + } + + /** Guest display follows the area: zoom disabled, area changes request a guest resize. */ + public void setAutoResize(boolean enabled) { + if (autoResize == enabled) { + return; + } + autoResize = enabled; + if (enabled && ready() && areaW > 0) { + fit(); + emit(); + listener.onGuestResizeWanted(areaW, areaH); + } + } + + public boolean isFitted() { + return ready() && areaW > 0 && scale <= fitScale() * FIT_SNAP; + } + + public boolean isFrozen() { + return frozen; + } + + /** Absolute scale, screen px per content px. */ + public float getScale() { + return scale; + } + + private boolean ready() { + return contentW > 0 && contentH > 0; + } + + private float fitScale() { + return Math.min(areaW / (float) contentW, areaH / (float) contentH); + } + + private void fit() { + scale = fitScale(); + offsetX = 0; + offsetY = 0; + } + + // Pan bounds: the image may only pan along an axis where it overflows the area, and never far + // enough to pull its edge inside the area (no gaps while zoomed). Letterboxed axes lock to 0. + private void clampOffset() { + float halfGapX = Math.max(0, (contentW * scale - areaW) / 2f); + float halfGapY = Math.max(0, (contentH * scale - areaH) / 2f); + offsetX = clamp(offsetX, -halfGapX, halfGapX); + offsetY = clamp(offsetY, -halfGapY, halfGapY); + } + + private void emit() { + float fit = fitScale(); + int baseW = Math.round(contentW * fit); + int baseH = Math.round(contentH * fit); + listener.onViewportChanged(baseW, baseH, scale / fit, offsetX, offsetY); + } + + private static float clamp(float v, float lo, float hi) { + return Math.max(lo, Math.min(v, hi)); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/HoldKeyGroup.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/HoldKeyGroup.java new file mode 100644 index 00000000..e3ae38e0 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/HoldKeyGroup.java @@ -0,0 +1,142 @@ +// 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.ui.vm.display.base; + +import static android.view.HapticFeedbackConstants.KEYBOARD_TAP; + +import android.annotation.SuppressLint; +import android.graphics.Rect; +import android.view.MotionEvent; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Hold-to-press keys with glide, for one keyboard widget: finger down on a key sends its key-down; + * sliding onto another registered key while held sends that key's down and the previous key's up + * (down-before-up, so a W-to-A roll never has a gap); release - or sliding off every key - sends + * key-up. The guest sees real holds, so auto-repeat and hold semantics are its own, and several + * pointers can hold several keys at once (each pointer's stream stays with its origin view; this + * group just retargets which key that pointer currently presses). + * + * Sticky modifiers are deliberately NOT registered here: gliding across one must not toggle it. + */ +public final class HoldKeyGroup { + public interface Sink { + void onKey(int androidKeyCode, boolean down); + } + + private static final class Entry { + final View view; + final int keyCode; + + Entry(View view, int keyCode) { + this.view = view; + this.keyCode = keyCode; + } + } + + private final List entries = new ArrayList<>(); + @NonNull + private final Sink sink; + + public HoldKeyGroup(@NonNull Sink sink) { + this.sink = sink; + } + + @SuppressLint("ClickableViewAccessibility") + public void register(@NonNull View view, int keyCode) { + var entry = new Entry(view, keyCode); + entries.add(entry); + view.setOnTouchListener(new PointerTracker(entry)); + } + + private void press(@NonNull Entry e) { + e.view.setPressed(true); + e.view.performHapticFeedback(KEYBOARD_TAP); + sink.onKey(e.keyCode, true); + } + + private void release(@NonNull Entry e) { + e.view.setPressed(false); + sink.onKey(e.keyCode, false); + } + + /** Tracks the single pointer whose ACTION_DOWN landed on one origin key. */ + private final class PointerTracker implements View.OnTouchListener { + private final Entry origin; + // Screen-space bounds of every visible registered key, cached per gesture (layout does + // not change mid-touch). + private final Map bounds = new HashMap<>(); + @Nullable + private Entry current; + + PointerTracker(@NonNull Entry origin) { + this.origin = origin; + } + + @Override + public boolean onTouch(View v, MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + cacheBounds(); + current = origin; + press(origin); + return true; + case MotionEvent.ACTION_MOVE: + onMove(event.getRawX(), event.getRawY()); + return true; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + if (current != null) { + release(current); + current = null; + } + return true; + } + return false; + } + + private void onMove(float rawX, float rawY) { + int x = Math.round(rawX), y = Math.round(rawY); + if (current != null) { + var rect = bounds.get(current); + if (rect != null && rect.contains(x, y)) return; + } + var target = hitTest(x, y); + if (target == current) return; + // Down-before-up on a roll so the guest never sees a hold gap. + if (target != null) press(target); + if (current != null) release(current); + current = target; + } + + @Nullable + private Entry hitTest(int x, int y) { + for (var e : entries) { + var rect = bounds.get(e); + // A key another pointer is holding can't be glided onto. + if (rect != null && rect.contains(x, y) && !e.view.isPressed()) return e; + } + return null; + } + + private void cacheBounds() { + bounds.clear(); + int[] loc = new int[2]; + for (var e : entries) { + if (!e.view.isShown()) continue; + e.view.getLocationOnScreen(loc); + bounds.put(e, new Rect( + loc[0], loc[1], loc[0] + e.view.getWidth(), loc[1] + e.view.getHeight())); + } + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/InputMode.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/InputMode.java new file mode 100644 index 00000000..4fc5ea66 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/InputMode.java @@ -0,0 +1,54 @@ +// 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.ui.vm.display.base; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; + +import cn.classfun.droidvm.R; + +/** + * Pointer input mode shared by both display backends (VNC and native). The control bar exposes a + * selector; each {@link DisplayInputBackend} interprets the current mode when it turns on-screen + * touches into guest input: + * + *
      + *
    • {@link #TOUCH} -- absolute multi-touch: fingers map straight to guest coordinates, multiple + * contacts preserved (the default; what a touchscreen guest expects).
    • + *
    • {@link #MOUSE} -- relative pointer: drags become REL_X/REL_Y deltas with button/wheel + * emulation. Android has no absolute-mouse concept, so this is the only faithful mouse path + * for guests that want relative motion (FPS games, desktops with pointer acceleration).
    • + *
    • {@link #TABLET} -- absolute single pointer (stylus/graphics-tablet): one contact mapped to + * absolute guest coordinates, no multi-finger gestures.
    • + *
    + * + * The ordinal is persisted (SharedPreferences {@code display_input_mode}); TOUCH/MOUSE keep their + * historical 0/1 values so existing settings stay valid, TABLET is appended as 2. + */ +public enum InputMode { + TOUCH(R.string.vnc_menu_input_mode_touch), + MOUSE(R.string.vnc_menu_input_mode_mouse), + TABLET(R.string.vnc_menu_input_mode_tablet); + + @StringRes + public final int labelResId; + + InputMode(@StringRes int labelResId) { + this.labelResId = labelResId; + } + + /** Persisted-ordinal lookup that never throws; unknown/out-of-range values fall back to TOUCH. */ + @NonNull + public static InputMode fromOrdinal(int ordinal) { + InputMode[] values = values(); + return (ordinal >= 0 && ordinal < values.length) ? values[ordinal] : TOUCH; + } + + /** The next mode in the cycle, for a single toggle button that rotates TOUCH -> MOUSE -> TABLET. */ + @NonNull + public InputMode next() { + InputMode[] values = values(); + return values[(ordinal() + 1) % values.length]; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/KeyListener.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/KeyListener.java index 79433a4f..462883e4 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/KeyListener.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/KeyListener.java @@ -1,18 +1,17 @@ +// 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.ui.vm.display.base; public interface KeyListener { - @SuppressWarnings("unused") - void onKeyRepeat(int androidKeyCode); + /** + * A non-modifier key went down ({@code down=true}) or up. The guest sees the real hold, so + * auto-repeat and hold semantics (e.g. WASD movement) are the guest's own; several keys may + * be held at once. + */ + void onKey(int androidKeyCode, boolean down); - @SuppressWarnings("unused") - void onCharRepeat(char ch); - - @SuppressWarnings("unused") - void onCapsToggle(boolean active); - - @SuppressWarnings("unused") void onModifierClick(int androidKeyCode); - @SuppressWarnings("unused") void onModifierLongClick(int androidKeyCode); } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/KeyboardMode.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/KeyboardMode.java new file mode 100644 index 00000000..fd34c92c --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/KeyboardMode.java @@ -0,0 +1,48 @@ +// 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.ui.vm.display.base; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import cn.classfun.droidvm.R; + +/** + * Which typing surface the display shows. Exactly one is active, and it decides what the Main + * zone of the on-screen keyboard is; the Extra and FNx zones are independent toggles that only + * apply while a keyboard is up ({@link #NONE} hides everything without forgetting them). + */ +public enum KeyboardMode { + /** Nothing shown - the display gets the whole screen. */ + NONE(R.string.keyboard_mode_none), + /** Main is one row of the keys a soft keyboard lacks; text comes from the system IME. */ + SYSTEM(R.string.keyboard_mode_system), + /** Main is the on-screen laptop keyboard; no IME involved. */ + LAPTOP(R.string.keyboard_mode_laptop); + + private final @StringRes int stringId; + + KeyboardMode(@StringRes int stringId) { + this.stringId = stringId; + } + + @StringRes + public int getStringId() { + return stringId; + } + + public boolean showsKeyboard() { + return this != NONE; + } + + @NonNull + public static KeyboardMode fromName(@Nullable String name) { + if (name != null) { + for (var m : values()) + if (m.name().equals(name)) return m; + } + return SYSTEM; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerGestureTranslator.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerGestureTranslator.java new file mode 100644 index 00000000..18a3af0d --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/PointerGestureTranslator.java @@ -0,0 +1,470 @@ +// 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.ui.vm.display.base; + +import android.graphics.RectF; +import android.os.Handler; +import android.view.MotionEvent; + +import androidx.annotation.NonNull; + +/** + * Unified pointer-gesture state machine for the MOUSE and TABLET input modes, shared by the VNC and + * native display paths. The activity feeds raw touch {@link MotionEvent}s in the coordinates of the + * gesture surface (the whole display container, letterbox included) together with the rectangle the + * guest frame is actually rendered into; the translator classifies them and emits semantic + * callbacks; each display backend maps those onto its wire format (evdev via crosvm --input, or RFB + * pointer events). + * + * Gestures: + *
      + *
    • One finger -- MOUSE: drag = relative cursor motion (the guest renders the cursor), quick tap + * = left click, tap-and-a-half (tap, then touch-and-move within the tap-drag window) = left + * drag with no leading click, libinput-style. TABLET: press is committed as a left-button-down + * at the absolute position after a short defer window (so a second finger can still turn it + * into a right-click), then drag draws; quick tap = left click.
    • + *
    • Two fingers -- a quick second-finger tap (contact count 1->2->1 where finger 2's press + * time < threshold and it barely moved) = right click, positioned at finger 1 (tablet). + * Two fingers panning together = scroll-wheel notches.
    • + *
    • Three fingers -- local display zoom/pan: emitted as view-space transform deltas, applied to + * the display view only, never sent to the guest.
    • + *
    + * + * Active area: the whole gesture surface is usable; only gestures that carry a coordinate to the + * guest are pinned to the display rect. MOUSE (relative) is never pinned. TABLET pins on where + * finger 1 went down: press/drag/tap, the right-click anchor and the scroll anchor (the scroll's + * coordinate is sampled at press time; the panning after it may leave the rect freely). The + * second finger of a right-click and the three-finger zoom/pan are position-free and work + * anywhere, letterbox included. + * + * TOUCH (multi-touch) mode bypasses this class entirely. + * + * All methods run on the UI thread (touch dispatch). The tablet press-defer uses the supplied + * {@link Handler} (main looper). + */ +public final class PointerGestureTranslator { + public interface Listener { + /** MOUSE mode: relative cursor motion, already scaled to guest pixels. */ + void onRelativeMove(float dxGuest, float dyGuest); + + /** TABLET mode: absolute pointer position (no button change), guest pixels. */ + void onAbsoluteMove(float xGuest, float yGuest); + + /** Left button transition. TABLET uses the coordinates; MOUSE may ignore them. */ + void onLeftButton(boolean down, float xGuest, float yGuest); + + /** Quick single-finger tap = left click (down+up). */ + void onLeftTap(float xGuest, float yGuest); + + /** Two-finger quick tap = right click; coordinates are finger 1's position (tablet). */ + void onRightClick(float xGuest, float yGuest); + + /** Two-finger pan, quantized to wheel notches (+v = scroll up, +h = scroll right). */ + void onScroll(int vNotches, int hNotches); + + /** + * Three-finger pinch/pan: local display transform. scaleFactor is relative to the previous + * event; pan/focus are view pixels. Never forwarded to the guest. + */ + void onZoomPan(float scaleFactor, float dxView, float dyView, float focusX, float focusY); + } + + private static final long TAP_MS = 250; // max press duration for a tap + private static final long RIGHT_TAP_MS = 300; // max finger-2 press duration for right click + // Mouse tap-and-drag window (libinput-style): after a tap the left button is held DOWN, and its + // release is deferred this long. A finger returning within the window continues the SAME press + // -- moving it drags (one clean press, no leading click); a second quick tap becomes a + // double-click; nothing means a plain click completes. Kept short so single clicks feel + // immediate; the tradeoff is the tap-drag re-touch must be quick. + private static final long TAP_DRAG_MS = 100; + private static final long TABLET_DEFER_MS = 60; // tablet: defer left-down so 2nd finger can veto + private static final float TAP_SLOP = 18f; // view px of travel before a press is a drag + private static final float SCROLL_NOTCH_PX = 64f; // view px of two-finger pan per wheel notch + + // TAP_WAIT: mouse tap done, left button held, waiting out the tap-drag window for a return + // touch. DRAG_HELD: a finger returned within the window, button still held, not yet moved far + // enough to be a drag (vs a double-tap). + private enum State {IDLE, PENDING1, DRAG_MOVE, DRAG_LEFT, TAP_WAIT, DRAG_HELD, TWO, SCROLL2, + THREE, DEAD} + + private final Handler handler; + private final Listener listener; + private boolean absolute; // tablet=true, mouse=false + + private State state = State.IDLE; + // display rect + surface->guest scale, refreshed on every event so layout changes are picked up + private final RectF dispRect = new RectF(0, 0, 1, 1); + private float guestW = 1f, guestH = 1f; + private float scaleX = 1f, scaleY = 1f; + + private int finger1Id = -1; + private float f1DownX, f1DownY, f1LastX, f1LastY; + private long f1DownTime; + // Finger 1 went down inside the display rect: TABLET coordinate ops (press/tap/drag, + // right-click anchor, scroll anchor) are only valid then. Irrelevant in MOUSE mode. + private boolean f1InDisplay; + + private int finger2Id = -1; + private float f2DownX, f2DownY; + private long f2DownTime; + private boolean twoMoved; + private float scrollAccumV, scrollAccumH; + private float twoLastMidX, twoLastMidY; + + private float threeLastCx, threeLastCy, threeLastSpread; + + private final Runnable tabletCommit = this::commitTabletPress; + private final Runnable tapRelease = this::commitTapRelease; + + public PointerGestureTranslator(@NonNull Handler handler, @NonNull Listener listener) { + this.handler = handler; + this.listener = listener; + } + + /** Switch between TABLET (absolute) and MOUSE (relative) semantics. Resets in-flight state. */ + public void setAbsolute(boolean absolute) { + if (this.absolute != absolute) reset(); + this.absolute = absolute; + } + + public void reset() { + handler.removeCallbacks(tabletCommit); + handler.removeCallbacks(tapRelease); + // Never leave the guest's left button stuck down if we tear down mid-press. + if (state == State.DRAG_LEFT || state == State.DRAG_HELD || state == State.TAP_WAIT) + listener.onLeftButton(false, guestX(f1LastX), guestY(f1LastY)); + state = State.IDLE; + finger1Id = -1; + finger2Id = -1; + } + + /** + * Feeds one touch event. Returns true if consumed. + * + * @param displayRect where the guest frame is rendered, in the event's coordinate space (the + * letterbox-fitted display view under any local zoom/pan transform) + * @param guestWidth guest coordinate range the display rect maps onto (fb px or the + * normalized evdev ABS range, whatever the backend's wire format wants) + * @param guestHeight guest coordinate range the display rect maps onto + */ + public boolean onTouchEvent(@NonNull MotionEvent ev, @NonNull RectF displayRect, + float guestWidth, float guestHeight) { + if (displayRect.width() <= 0f || displayRect.height() <= 0f) return false; + dispRect.set(displayRect); + guestW = guestWidth; + guestH = guestHeight; + scaleX = guestWidth / displayRect.width(); + scaleY = guestHeight / displayRect.height(); + switch (ev.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + onFirstDown(ev); + return true; + case MotionEvent.ACTION_POINTER_DOWN: + onExtraDown(ev); + return true; + case MotionEvent.ACTION_MOVE: + onMove(ev); + return true; + case MotionEvent.ACTION_POINTER_UP: + onPointerUp(ev); + return true; + case MotionEvent.ACTION_UP: + onLastUp(ev); + return true; + case MotionEvent.ACTION_CANCEL: + cancel(); + return true; + default: + return false; + } + } + + /** Maps a gesture-surface coordinate to guest px, clamped to the guest bounds. */ + private float guestX(float x) { + return Math.max(0f, Math.min((x - dispRect.left) * scaleX, guestW - 1f)); + } + + private float guestY(float y) { + return Math.max(0f, Math.min((y - dispRect.top) * scaleY, guestH - 1f)); + } + + /** Whether TABLET coordinate ops are valid for this gesture (always true for MOUSE). */ + private boolean coordOps() { + return !absolute || f1InDisplay; + } + + private void onFirstDown(MotionEvent ev) { + finger1Id = ev.getPointerId(0); + f1DownX = f1LastX = ev.getX(0); + f1DownY = f1LastY = ev.getY(0); + f1DownTime = ev.getEventTime(); + // Mouse: a finger returning while the previous tap's release is still deferred continues + // the SAME held press. Moving it becomes a drag (no leading click); a quick lift is the + // second half of a double-click. See onLastUp/onMove for the split. + if (!absolute && state == State.TAP_WAIT) { + handler.removeCallbacks(tapRelease); + state = State.DRAG_HELD; + return; + } + f1InDisplay = dispRect.contains(f1DownX, f1DownY); + state = State.PENDING1; + if (absolute && f1InDisplay) { + // Tablet: commit the press after the defer window unless a second finger vetoes it. + // A press outside the display rect has no guest coordinate and never commits; the + // fingers are still tracked so multi-finger gestures can form from the letterbox. + handler.postDelayed(tabletCommit, TABLET_DEFER_MS); + } + } + + private void commitTabletPress() { + if (state != State.PENDING1 || !absolute) return; + state = State.DRAG_LEFT; + listener.onLeftButton(true, guestX(f1LastX), guestY(f1LastY)); + } + + // Mouse tap-drag window expired with no return touch: the deferred tap is just a click. + private void commitTapRelease() { + if (state != State.TAP_WAIT) return; + listener.onLeftButton(false, guestX(f1LastX), guestY(f1LastY)); + state = State.IDLE; + finger1Id = -1; + } + + private void onExtraDown(MotionEvent ev) { + int count = ev.getPointerCount(); + if (count == 2 && (state == State.PENDING1 || state == State.DRAG_MOVE + || state == State.DRAG_LEFT || state == State.DRAG_HELD)) { + handler.removeCallbacks(tabletCommit); + if (state == State.DRAG_LEFT || state == State.DRAG_HELD) { + // The press was already committed (or held from a tap); release it before + // switching to the two-finger gesture. + listener.onLeftButton(false, guestX(f1LastX), guestY(f1LastY)); + } + int idx = ev.getActionIndex(); + finger2Id = ev.getPointerId(idx); + f2DownX = ev.getX(idx); + f2DownY = ev.getY(idx); + f2DownTime = ev.getEventTime(); + twoMoved = false; + scrollAccumV = 0; + scrollAccumH = 0; + twoLastMidX = (f1LastX + f2DownX) / 2f; + twoLastMidY = (f1LastY + f2DownY) / 2f; + state = State.TWO; + } else if (count == 3 && (state == State.TWO || state == State.SCROLL2)) { + state = State.THREE; + threeLastCx = centroidX(ev); + threeLastCy = centroidY(ev); + threeLastSpread = spread(ev); + } else { + // 4+ fingers or unexpected ordering: bail until all fingers lift. + handler.removeCallbacks(tabletCommit); + state = State.DEAD; + } + } + + private void onMove(MotionEvent ev) { + switch (state) { + case PENDING1: { + int idx = ev.findPointerIndex(finger1Id); + if (idx < 0) return; + float x = ev.getX(idx), y = ev.getY(idx); + if (Math.abs(x - f1DownX) > TAP_SLOP || Math.abs(y - f1DownY) > TAP_SLOP) { + handler.removeCallbacks(tabletCommit); + if (absolute) { + if (f1InDisplay) { + // Commit press at the original touch point, then drag from there. + listener.onLeftButton(true, guestX(f1DownX), guestY(f1DownY)); + state = State.DRAG_LEFT; + } + // Outside the display rect a tablet drag has no coordinate; stay + // PENDING1 so extra fingers can still form the multi-finger gestures. + } else { + state = State.DRAG_MOVE; + } + } + if (coordOps()) emitPointerMove(x, y); + f1LastX = x; + f1LastY = y; + break; + } + case DRAG_MOVE: + case DRAG_LEFT: { + int idx = ev.findPointerIndex(finger1Id); + if (idx < 0) return; + float x = ev.getX(idx), y = ev.getY(idx); + emitPointerMove(x, y); + f1LastX = x; + f1LastY = y; + break; + } + case DRAG_HELD: { + // Return touch of a tap-drag: the left button is already held. Once it travels + // past the slop it's a drag; a lift before that (in onLastUp) is a double-click. + int idx = ev.findPointerIndex(finger1Id); + if (idx < 0) return; + float x = ev.getX(idx), y = ev.getY(idx); + if (Math.abs(x - f1DownX) > TAP_SLOP || Math.abs(y - f1DownY) > TAP_SLOP) + state = State.DRAG_LEFT; + if (state == State.DRAG_LEFT) emitPointerMove(x, y); + f1LastX = x; + f1LastY = y; + break; + } + case TWO: + case SCROLL2: { + int i1 = ev.findPointerIndex(finger1Id); + int i2 = ev.findPointerIndex(finger2Id); + if (i1 < 0 || i2 < 0) return; + float midX = (ev.getX(i1) + ev.getX(i2)) / 2f; + float midY = (ev.getY(i1) + ev.getY(i2)) / 2f; + float dx = midX - twoLastMidX; + float dy = midY - twoLastMidY; + if (state == State.TWO + && (Math.abs(midX - (f1DownX + f2DownX) / 2f) > TAP_SLOP + || Math.abs(midY - (f1DownY + f2DownY) / 2f) > TAP_SLOP)) { + twoMoved = true; + if (coordOps()) { + state = State.SCROLL2; + // The scroll's coordinate is taken at press time: pin the guest pointer + // to finger 1's down position so the wheel lands under the fingers; the + // panning after this is position-free and may leave the display rect. + if (absolute) + listener.onAbsoluteMove(guestX(f1DownX), guestY(f1DownY)); + } + } + if (state == State.SCROLL2) { + // Natural scrolling: content follows the fingers (pan down = wheel up). + scrollAccumV += dy; + scrollAccumH += dx; + int v = (int) (scrollAccumV / SCROLL_NOTCH_PX); + int h = (int) (scrollAccumH / SCROLL_NOTCH_PX); + if (v != 0 || h != 0) { + scrollAccumV -= v * SCROLL_NOTCH_PX; + scrollAccumH -= h * SCROLL_NOTCH_PX; + listener.onScroll(v, -h); + } + } + twoLastMidX = midX; + twoLastMidY = midY; + f1LastX = ev.getX(i1); + f1LastY = ev.getY(i1); + break; + } + case THREE: { + if (ev.getPointerCount() < 3) return; + float cx = centroidX(ev), cy = centroidY(ev); + float sp = spread(ev); + float factor = threeLastSpread > 1f ? sp / threeLastSpread : 1f; + listener.onZoomPan(factor, cx - threeLastCx, cy - threeLastCy, cx, cy); + threeLastCx = cx; + threeLastCy = cy; + threeLastSpread = sp; + break; + } + default: + break; + } + } + + private void emitPointerMove(float x, float y) { + if (absolute) { + listener.onAbsoluteMove(guestX(x), guestY(y)); + } else { + listener.onRelativeMove((x - f1LastX) * scaleX, (y - f1LastY) * scaleY); + } + } + + private void onPointerUp(MotionEvent ev) { + int id = ev.getPointerId(ev.getActionIndex()); + if ((state == State.TWO || state == State.SCROLL2) && id == finger2Id) { + long held = ev.getEventTime() - f2DownTime; + if (state == State.TWO && !twoMoved && held <= RIGHT_TAP_MS && coordOps()) { + // 1 -> 2 -> 1 with a quick second finger: right click at finger 1's position + // (finger 2 is position-free and may sit outside the display rect). + listener.onRightClick(guestX(f1LastX), guestY(f1LastY)); + } + // Whatever remains of the gesture is spent; ignore finger 1 until it lifts. + state = State.DEAD; + finger2Id = -1; + } else if ((state == State.TWO || state == State.SCROLL2) && id == finger1Id) { + // Finger 1 left first; treat the same as gesture end. + state = State.DEAD; + } else if (state == State.THREE && ev.getPointerCount() - 1 < 3) { + state = State.DEAD; + } + } + + private void onLastUp(MotionEvent ev) { + handler.removeCallbacks(tabletCommit); + switch (state) { + case PENDING1: { + long held = ev.getEventTime() - f1DownTime; + if (held <= TAP_MS) { + if (absolute) { + if (f1InDisplay) listener.onLeftTap(guestX(f1DownX), guestY(f1DownY)); + } else { + // Mouse tap: press now but defer the release, so a finger returning within + // the window continues it as a drag (no leading click) or a double-click. + listener.onLeftButton(true, guestX(f1DownX), guestY(f1DownY)); + state = State.TAP_WAIT; + handler.postDelayed(tapRelease, TAP_DRAG_MS); + return; // keep TAP_WAIT; don't fall through to the IDLE reset + } + } else if (absolute && f1InDisplay) { + // Long still hold that never got committed: emit press+release at the position. + listener.onLeftButton(true, guestX(f1DownX), guestY(f1DownY)); + listener.onLeftButton(false, guestX(f1DownX), guestY(f1DownY)); + } + break; + } + case DRAG_HELD: + // Return touch lifted without dragging: it was a double-tap. Release the held + // press (completes click 1), then emit a second click. + listener.onLeftButton(false, guestX(f1LastX), guestY(f1LastY)); + listener.onLeftButton(true, guestX(f1LastX), guestY(f1LastY)); + listener.onLeftButton(false, guestX(f1LastX), guestY(f1LastY)); + break; + case DRAG_LEFT: + listener.onLeftButton(false, guestX(f1LastX), guestY(f1LastY)); + break; + default: + break; + } + state = State.IDLE; + finger1Id = -1; + finger2Id = -1; + } + + private void cancel() { + handler.removeCallbacks(tabletCommit); + handler.removeCallbacks(tapRelease); + if (state == State.DRAG_LEFT || state == State.DRAG_HELD || state == State.TAP_WAIT) + listener.onLeftButton(false, guestX(f1LastX), guestY(f1LastY)); + state = State.IDLE; + finger1Id = -1; + finger2Id = -1; + } + + private static float centroidX(MotionEvent ev) { + float s = 0; + for (int i = 0; i < ev.getPointerCount(); i++) s += ev.getX(i); + return s / ev.getPointerCount(); + } + + private static float centroidY(MotionEvent ev) { + float s = 0; + for (int i = 0; i < ev.getPointerCount(); i++) s += ev.getY(i); + return s / ev.getPointerCount(); + } + + /** Mean distance of the pointers from their centroid; pinch ratio = spread/lastSpread. */ + private static float spread(MotionEvent ev) { + float cx = centroidX(ev), cy = centroidY(ev); + float s = 0; + for (int i = 0; i < ev.getPointerCount(); i++) + s += Math.hypot(ev.getX(i) - cx, ev.getY(i) - cy); + return s / ev.getPointerCount(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/UsbHidInput.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/UsbHidInput.java index d034ee04..d445fd23 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/UsbHidInput.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/UsbHidInput.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.ui.vm.display.base; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/ViewHeightAnimator.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/ViewHeightAnimator.java new file mode 100644 index 00000000..7174ec4a --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/ViewHeightAnimator.java @@ -0,0 +1,97 @@ +// 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.ui.vm.display.base; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.ValueAnimator; +import android.view.View; + +import androidx.annotation.NonNull; + +import cn.classfun.droidvm.R; + +/** + * Slide a bottom-docked row/panel open or closed by animating its layout height. The view's + * pre-animation layout height (fixed dp or WRAP_CONTENT) is remembered on first use and restored + * when the animation finishes - blindly resetting to WRAP_CONTENT would permanently squash rows + * whose height comes from their own fixed layout_height (their match_parent children then wrap to + * text height). + */ +public final class ViewHeightAnimator { + private static final long DURATION = 200; + + private ViewHeightAnimator() { + } + + public static void setVisible(@NonNull View view, boolean visible) { + if (visible) show(view); + else hide(view); + } + + /** The view's own layout height before any animation touched it. */ + private static int originalHeight(@NonNull View view) { + Object tag = view.getTag(R.id.view_height_animator_original); + if (tag instanceof Integer) return (Integer) tag; + int height = view.getLayoutParams().height; + view.setTag(R.id.view_height_animator_original, height); + return height; + } + + public static void show(@NonNull View view) { + if (view.getVisibility() == View.VISIBLE) return; + int original = originalHeight(view); + view.setVisibility(View.VISIBLE); + int target; + if (original > 0) { + target = original; + } else { + view.measure( + View.MeasureSpec.makeMeasureSpec( + ((View) view.getParent()).getWidth(), View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ); + target = view.getMeasuredHeight(); + } + var lp = view.getLayoutParams(); + lp.height = 0; + view.requestLayout(); + var anim = ValueAnimator.ofInt(0, target); + anim.setDuration(DURATION); + anim.addUpdateListener(a -> { + lp.height = (int) a.getAnimatedValue(); + view.requestLayout(); + }); + anim.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator a) { + lp.height = original; + view.requestLayout(); + } + }); + anim.start(); + } + + public static void hide(@NonNull View view) { + if (view.getVisibility() == View.GONE) return; + int original = originalHeight(view); + int start = view.getHeight(); + var lp = view.getLayoutParams(); + var anim = ValueAnimator.ofInt(start, 0); + anim.setDuration(DURATION); + anim.addUpdateListener(a -> { + lp.height = (int) a.getAnimatedValue(); + view.requestLayout(); + }); + anim.addListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator a) { + view.setVisibility(View.GONE); + lp.height = original; + view.requestLayout(); + } + }); + anim.start(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/X11Keymap.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/X11Keymap.java index 22921d5e..7f905be0 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/X11Keymap.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/base/X11Keymap.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.ui.vm.display.base; import android.view.KeyEvent; @@ -8,6 +11,7 @@ public final class X11Keymap { public static final int XK_Tab = 0xff09; /* U+0009 CHARACTER TABULATION */ public static final int XK_Clear = 0xff0b; /* U+000B LINE TABULATION */ public static final int XK_Return = 0xff0d; /* U+000D CARRIAGE RETURN */ + public static final int XK_Pause = 0xff13; public static final int XK_Scroll_Lock = 0xff14; public static final int XK_Sys_Req = 0xff15; public static final int XK_Escape = 0xff1b; /* U+001B ESCAPE */ @@ -232,10 +236,12 @@ public static int androidKeyToXKeysym(int keyCode) { return XK_Num_Lock; case KeyEvent.KEYCODE_FUNCTION: return XF86XK_Fn; + // The unmodified keys: X servers derive Sys_Req/Break themselves when Alt/Ctrl is + // held, and VNC-server keymaps often only know the unmodified symbols. case KeyEvent.KEYCODE_SYSRQ: - return XK_Sys_Req; + return XK_Print; case KeyEvent.KEYCODE_BREAK: - return XK_Break; + return XK_Pause; case KeyEvent.KEYCODE_SPACE: return XK_space; case KeyEvent.KEYCODE_GRAVE: diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/CursorPositionStream.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/CursorPositionStream.java new file mode 100644 index 00000000..c1190049 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/CursorPositionStream.java @@ -0,0 +1,140 @@ +// 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.ui.vm.display.nativedisplay.display; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.crosvm.ICrosvmAndroidDisplayService; +import android.os.Handler; +import android.os.Looper; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * The guest cursor's position, straight from virtio-gpu. + * + * crosvm's Android display backend already writes every cursor move to a pipe -- see + * set_android_surface_position() in crosvm_android_display_client.cpp, which does nothing but + * write(fd, {x, y}). Until now nothing ever called setCursorStream, so that fd stayed -1 and the + * backend logged "cursor position stream is not attached; dropping position updates" once and + * threw every update away. This class is the missing other end. + * + * WHY THIS AND NOT THE APP'S OWN TOUCH COORDINATES + * + * The app knows where the user touched, which is not where the guest's pointer is: + * - in relative-pointer mode the app sends DELTAS, so it has no absolute position at all; + * - the guest warps the pointer on its own (games grabbing it, dialogs centring it); + * - while zoomed there is a scale factor between the two, so any app-side dead reckoning drifts. + * These x/y come from the guest's own cursor plane, which is the only authoritative source. + * + * Values are little-endian u32 pairs in GUEST framebuffer coordinates. + */ +final class CursorPositionStream implements AutoCloseable { + + /** Delivered on the main thread. */ + interface Listener { + void onCursorMoved(int guestX, int guestY); + } + + private static final String TAG = "CursorPositionStream"; + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + private final Listener listener; + private ParcelFileDescriptor readSide; + private Thread reader; + private volatile boolean closed; + + private CursorPositionStream(@NonNull Listener listener) { + this.listener = listener; + } + + /** + * Creates the pipe, hands the WRITE end to crosvm and starts reading the other end. + * Returns null if the service refuses it, in which case the caller simply has no cursor + * position -- everything else keeps working. + */ + static CursorPositionStream attach(@NonNull ICrosvmAndroidDisplayService service, + @NonNull Listener listener) { + ParcelFileDescriptor[] pipe; + try { + pipe = ParcelFileDescriptor.createPipe(); + } catch (IOException e) { + Log.w(TAG, "could not create cursor pipe", e); + return null; + } + var self = new CursorPositionStream(listener); + self.readSide = pipe[0]; + try { + // The binder call dups the fd, so our copy must be closed either way -- crosvm keeps + // its own. Leaving it open here would mean the reader never sees EOF when crosvm exits. + service.setCursorStream(pipe[1]); + } catch (Exception e) { + Log.w(TAG, "setCursorStream failed; cursor position unavailable", e); + closeQuietly(pipe[0]); + closeQuietly(pipe[1]); + return null; + } finally { + closeQuietly(pipe[1]); + } + + self.reader = new Thread(self::readLoop, "CursorPositionStream"); + self.reader.setDaemon(true); + self.reader.start(); + Log.i(TAG, "cursor position stream attached"); + return self; + } + + private void readLoop() { + try (InputStream in = new ParcelFileDescriptor.AutoCloseInputStream(readSide)) { + var din = new DataInputStream(in); + byte[] buf = new byte[8]; + while (!closed) { + // readFully, not read: the writer emits 8-byte records and a short read would + // desynchronise the stream permanently, turning every later position into noise. + din.readFully(buf); + int x = le32(buf, 0); + int y = le32(buf, 4); + mainHandler.post(() -> { + if (!closed) { + listener.onCursorMoved(x, y); + } + }); + } + } catch (IOException e) { + if (!closed) { + // Normal at VM shutdown: crosvm exits and the write end closes. + Log.i(TAG, fmt("cursor position stream ended: %s", e.getMessage())); + } + } + } + + private static int le32(byte[] b, int off) { + return (b[off] & 0xFF) + | ((b[off + 1] & 0xFF) << 8) + | ((b[off + 2] & 0xFF) << 16) + | ((b[off + 3] & 0xFF) << 24); + } + + @Override + public void close() { + closed = true; + closeQuietly(readSide); // unblocks readFully + readSide = null; + } + + private static void closeQuietly(ParcelFileDescriptor pfd) { + if (pfd == null) return; + try { + pfd.close(); + } catch (IOException ignored) { + // Nothing useful to do; the fd is going away either way. + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/DisplayProvider.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/DisplayProvider.java index 524e5db1..351b52b2 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/DisplayProvider.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/DisplayProvider.java @@ -1,8 +1,12 @@ +// 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.ui.vm.display.nativedisplay.display; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.crosvm.DisplayConfig; import android.crosvm.ICrosvmAndroidDisplayService; +import android.os.Build; import android.os.DeadObjectException; import android.os.Handler; import android.os.IBinder; @@ -17,6 +21,7 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import java.util.function.Supplier; @@ -32,9 +37,30 @@ */ final class DisplayProvider { private static final String TAG = "NativeDisplayProvider"; - // Stop polling for the display binder after this many 1s rounds (the daemon-side - // waitForService already blocks up to 5s each), so a dead broker doesn't spin forever. - private static final int MAX_BINDER_ATTEMPTS = 30; + // Keep looking for the display binder for as long as this console is open, backing off from + // BINDER_RETRY_MIN_MS to BINDER_RETRY_MAX_MS between rounds. + // + // There used to be a cap of 30 rounds, because each round could cost the daemon a leaked, + // permanently-blocked thread (see NativeDisplayBinder) and spinning was genuinely expensive. + // It no longer is -- a round is one cheap ServiceManager lookup, answered instantly when the + // VM is not running -- and a cap is the wrong shape anyway: the thing being waited for is a + // guest booting, which has no upper bound. A guest that took longer than the cap left the + // console blank for the rest of its life with nothing left to retry it. + // + // Two things end the wait instead of a counter: the binder turning up, and the console + // closing (shutdown() interrupts this thread). A VM that exits while the console is open + // closes the console itself, so "the VM is never coming back" ends it too. + private static final long BINDER_RETRY_MIN_MS = 1000; + private static final long BINDER_RETRY_MAX_MS = 5000; + // Interval for re-reading the guest display size. crosvm's binder is pull-only (no resize + // push callback, and SELinux blocks crosvm from calling back into an untrusted_app), so the + // receiver polls the single source of truth -- getDisplayConfig() reflects the current scanout + // size, updated by C++ configure() on EVERY resolution change (UEFI modeset, Linux boot, and + // Linux runtime xrandr all flow through it). This is what lets the aspect ratio follow a guest + // resolution change mid-session. A resize is rare and user-driven, so 1s lag is fine. + private static final long CONFIG_POLL_MS = 1000; + /** virtio-gpu's cursor plane size; a smaller cursor image lands in its top-left. */ + static final int CURSOR_PLANE_PX = 64; private final SurfaceView mainView; private int width; @@ -51,8 +77,15 @@ final class DisplayProvider { private ICrosvmAndroidDisplayService displayService; private boolean needsSend = false; private boolean hasSavedFrame = false; + private boolean hasSavedCursorFrame = false; + private CursorPositionStream cursorStream; + private CursorPositionStream.Listener cursorListener; + private SurfaceView cursorView; + private boolean cursorSurfaceSent = false; private final IBinder.DeathRecipient deathRecipient; + /** One binder hunt at a time: both the death path and surfaceCreated ask for one. */ + private final AtomicBoolean fetching = new AtomicBoolean(); DisplayProvider(@NonNull SurfaceView mainView, int width, int height, @NonNull Supplier binderProvider, @@ -68,10 +101,41 @@ final class DisplayProvider { Log.w(TAG, "display service died - connection lost"); onConnected.accept(false); displayService = null; + // The display service lives in crosvm. When the guest reboots (or crosvm otherwise + // restarts) the daemon relaunches crosvm, which registers a NEW display service under + // the same name -- but nothing re-fetches it, so the activity sat on "waiting for the + // VM screen" until the user closed and reopened it (seen on every provisioning reboot). + // Re-arm: the surface we hold is still valid, so mark it for re-delivery and poll for + // the new binder the same way the first attach did. shutdown() has already stopped + // the executor when the activity is going away, so a real teardown does not re-poll. + if (executor.isShutdown()) { + needsSend = false; + return; + } needsSend = false; + cursorSurfaceSent = false; + hasSavedFrame = false; + hasSavedCursorFrame = false; + if (cursorStream != null) { + cursorStream.close(); + cursorStream = null; + } + // Do NOT hand the old Surface to the new crosvm. Its BufferQueue still carries the + // state the dead producer left behind (buffers dequeued and never returned): the CPU + // console path can still post to it, but the GPU-blit scanout path silently stops -- + // observed as the early-boot console frozen on screen with a live cursor. Closing and + // reopening the activity fixed it because that creates fresh surfaces, so do exactly + // that here: bounce both SurfaceViews so surfaceDestroyed/surfaceCreated run and the + // usual send-once-per-surface path delivers brand-new surfaces once the binder is back. + recreateSurface(mainView); + if (cursorView != null) recreateSurface(cursorView); + Log.i(TAG, "re-fetching the display binder for the restarted VM (fresh surfaces)"); + fetchBinder(); }); - mainView.setSurfaceLifecycle(SurfaceView.SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + mainView.setSurfaceLifecycle(SurfaceView.SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT); + } mainView.getHolder().addCallback(new Callback()); var surface = mainView.getHolder().getSurface(); @@ -82,35 +146,62 @@ final class DisplayProvider { fetchBinder(); } + /* Tear down and re-create a SurfaceView's surface (fresh BufferQueue). On Android 14+ the main + * view uses SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT, so visibility does not govern the surface (a + * GONE/VISIBLE bounce was seen to do nothing, or to destroy the surface tens of seconds later + * and never bring it back). Android 13 keeps the platform's default lifecycle, but detaching is + * still the reliable way to force surfaceDestroyed. Re-adding then creates a brand-new surface + * and the usual send-once-per-surface path delivers it once the binder is back. */ + private void recreateSurface(@NonNull SurfaceView view) { + var parent = view.getParent(); + if (!(parent instanceof android.view.ViewGroup)) { + Log.w(TAG, "recreateSurface: view has no ViewGroup parent"); + return; + } + var group = (android.view.ViewGroup) parent; + int idx = group.indexOfChild(view); + var lp = view.getLayoutParams(); + Log.i(TAG, fmt("recreateSurface: detaching and re-attaching %s", + view.getClass().getSimpleName())); + group.removeView(view); + group.addView(view, idx, lp); + } + private void fetchBinder() { + if (!fetching.compareAndSet(false, true)) return; executor.submit(() -> { - IBinder binder = null; - int attempts = 0; - while (!Thread.currentThread().isInterrupted() && attempts < MAX_BINDER_ATTEMPTS) { - try { - binder = binderProvider.get(); - } catch (Exception e) { - Log.e(TAG, "binderProvider threw", e); - binder = null; - } - if (binder != null) break; - attempts++; - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; + try { + long backoff = BINDER_RETRY_MIN_MS; + int rounds = 0; + while (!Thread.currentThread().isInterrupted()) { + IBinder binder; + try { + binder = binderProvider.get(); + } catch (Exception e) { + Log.e(TAG, "binderProvider threw", e); + binder = null; + } + if (binder != null) { + final IBinder got = binder; + mainHandler.post(() -> onBinderReady(got)); + return; + } + // One line early and then one a minute: a slow guest is normal and must not + // be the reason a log is unreadable. + if (rounds == 0 || rounds % 60 == 0) + Log.i(TAG, fmt("display binder not there yet (round %d)", rounds + 1)); + rounds++; + try { + Thread.sleep(backoff); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + backoff = Math.min(backoff * 2, BINDER_RETRY_MAX_MS); } + } finally { + fetching.set(false); } - final IBinder got = binder; - if (got == null) { - // Give up rather than poll forever: the daemon broker may have died (the supplier - // returns null once rootService is dropped), so the display can't be reached. - Log.e(TAG, fmt("display binder unavailable after %d attempts", MAX_BINDER_ATTEMPTS)); - mainHandler.post(() -> onConnected.accept(false)); - return; - } - mainHandler.post(() -> onBinderReady(got)); }); } @@ -133,8 +224,53 @@ private void onBinderReady(@NonNull IBinder binder) { } catch (Exception e) { Log.w(TAG, fmt("getDisplayConfig unavailable, using default %dx%d", width, height)); } + // Attach the cursor position pipe, if anybody wants it. crosvm has always written guest + // cursor moves to this fd; nothing ever read it, so the backend dropped them all. + attachCursorStream(); + if (cursorView != null) { + trySendCursorSurface(cursorView.getHolder()); + } + onConnected.accept(true); applyPendingSurface(); + startConfigPoll(displayService); + } + + // Watches for guest resolution changes: crosvm has no push channel, so re-read the current + // scanout size on the bg thread and, when it changes, re-lay-out the surface. Runs on the + // single background executor; a getDisplayConfig() failure (dead service) ends the loop. + private void startConfigPoll(@NonNull ICrosvmAndroidDisplayService svc) { + executor.submit(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + Thread.sleep(CONFIG_POLL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + DisplayConfig config; + try { + config = svc.getDisplayConfig(); + } catch (Exception e) { + return; // service gone; deathRecipient handles teardown + } + if (config == null || config.width <= 0 || config.height <= 0) continue; + mainHandler.post(() -> applyConfigIfChanged(config)); + } + }); + } + + // Main thread: apply a newly observed guest size. width/height stay main-thread-only. + private void applyConfigIfChanged(@NonNull DisplayConfig config) { + if (config.width == width && config.height == height) return; + Log.i(TAG, fmt("guest display size changed %dx%d -> %dx%d", + width, height, config.width, config.height)); + width = config.width; + height = config.height; + // Keep the app-side buffer size in step with crosvm's setBuffersGeometry; guarded by + // needsSend, so the layout-driven surfaceChanged this triggers won't re-send the surface. + mainView.getHolder().setFixedSize(width, height); + onDisplayConfig.accept(config); } private void applyPendingSurface() { @@ -212,6 +348,115 @@ public void surfaceDestroyed(@NonNull SurfaceHolder holder) { } } + /** + * Ask for guest cursor positions. Safe to call before or after the display binder arrives: + * whichever happens second does the attaching, so a caller does not have to know which. + */ + void setCursorListener(CursorPositionStream.Listener listener) { + cursorListener = listener; + attachCursorStream(); + } + + private void attachCursorStream() { + if (cursorListener == null || displayService == null || cursorStream != null) { + return; + } + cursorStream = CursorPositionStream.attach(displayService, cursorListener); + } + + /** + * Give crosvm a Surface for the guest's HARDWARE cursor. + * + * Until something calls setSurface(_, forCursor=true), the native backend's cursor surface has + * no native window: lock() hands crosvm a sink buffer whose own comment says it "is never + * displayed on the physical screen", and unlockAndPost() returns without posting. The pointer + * is rendered perfectly and thrown away. + * + * Linux guests can be told to draw the pointer into the framebuffer instead, which is why this + * went unnoticed; Windows' virtio-gpu driver and UEFI have no equivalent and use the cursor + * plane unconditionally, so without this they have no visible pointer at all. + */ + void setCursorView(SurfaceView view) { + cursorView = view; + if (view == null) { + return; + } + // Above the scanout SurfaceView. Both are surfaces in their own layers, so ordinary view + // z-order does not apply -- without this the cursor is composited BEHIND the display. + view.setZOrderMediaOverlay(true); + // Same rule as the scanout: this Surface must outlive a visibility change. The guest hides + // and re-shows its pointer constantly -- KDE drops to a software cursor whenever the + // hardware plane cannot keep up with a fast move and hands the plane straight back -- and + // the image comes back in ONE UPDATE_CURSOR, whose pixels are flushed microseconds after + // the position that tells the app to show the overlay again. Re-creating a destroyed + // Surface takes a frame plus a binder round trip, so those pixels land in the backend's + // sink buffer and are dropped, and every later MOVE_CURSOR carries a position and no image + // -- the pointer stays blank until the guest happens to change its shape. The activity + // therefore parks the overlay off-screen rather than setting it GONE; on Android 14+ this + // makes the Surface immune to visibility for good measure. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + view.setSurfaceLifecycle(SurfaceView.SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT); + } + view.getHolder().setFormat(android.graphics.PixelFormat.TRANSLUCENT); + view.getHolder().setFixedSize(CURSOR_PLANE_PX, CURSOR_PLANE_PX); + view.getHolder().addCallback(new SurfaceHolder.Callback() { + @Override public void surfaceCreated(@NonNull SurfaceHolder h) { + cursorSurfaceSent = false; + trySendCursorSurface(h); + } + @Override public void surfaceChanged(@NonNull SurfaceHolder h, int f, int w, int hh) { + // Same rule as the main surface: setSurface at most once per surface lifetime. + trySendCursorSurface(h); + } + @Override public void surfaceDestroyed(@NonNull SurfaceHolder h) { + cursorSurfaceSent = false; + var svc = displayService; + if (svc == null) return; + // Keep the last pointer image the same way the scanout keeps its last frame. A + // cursor surface that really did die (the activity left the foreground, or the + // death path bounced it) comes back empty otherwise, and nothing repaints it: only + // UPDATE_CURSOR carries pixels, and a pointer that merely moves sends MOVE_CURSOR. + try { + svc.saveFrameForSurface(true); + hasSavedCursorFrame = true; + } catch (Exception e) { + Log.w(TAG, "saveFrameForSurface(cursor) failed", e); + } + try { + svc.removeSurface(true); + } catch (Exception e) { + Log.w(TAG, "removeSurface(cursor) failed", e); + } + } + }); + trySendCursorSurface(view.getHolder()); + } + + private void trySendCursorSurface(@NonNull SurfaceHolder holder) { + var svc = displayService; + if (svc == null || cursorSurfaceSent) return; + Surface surface = holder.getSurface(); + if (surface == null || !surface.isValid()) return; + try { + svc.setSurface(surface, true); + cursorSurfaceSent = true; + Log.i(TAG, "cursor surface delivered"); + } catch (IllegalArgumentException e) { + // Same known/benign binder reply behaviour as the main surface. + cursorSurfaceSent = true; + } catch (Exception e) { + Log.w(TAG, "setSurface(cursor) failed", e); + return; + } + if (hasSavedCursorFrame) { + try { + svc.drawSavedFrameForSurface(true); + } catch (Exception e) { + Log.w(TAG, "drawSavedFrameForSurface(cursor) failed", e); + } + } + } + void shutdown() { if (displayService != null) { try { @@ -219,6 +464,10 @@ void shutdown() { } catch (Exception ignored) { } } + if (cursorStream != null) { + cursorStream.close(); + cursorStream = null; + } executor.shutdownNow(); displayService = null; needsSend = false; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/NativeSurfaceSource.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/NativeSurfaceSource.java new file mode 100644 index 00000000..6a839f1b --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/NativeSurfaceSource.java @@ -0,0 +1,74 @@ +// 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.ui.vm.display.nativedisplay.display; + +import android.os.Handler; +import android.os.IBinder; +import android.view.SurfaceView; + +import androidx.annotation.NonNull; + +import java.util.function.Supplier; + +import cn.classfun.droidvm.ui.vm.display.base.DisplaySource; + +/** + * {@link DisplaySource} for the native display path: crosvm renders straight into the + * SurfaceView's Surface via the per-VM ICrosvmAndroidDisplayService. Wraps {@link DisplayProvider} + * behind the pluggable source interface, so a future source (e.g. zero-copy AHardwareBuffer) + * slots into the same console. Frames start flowing as soon as the display-binder supplier can + * resolve the service, so {@link #start()} is a no-op. No guest-resize channel on this path yet. + */ +public final class NativeSurfaceSource implements DisplaySource { + private final DisplayProvider provider; + + /** + * @param displayBinderSupplier resolves the per-VM display binder (blocking, called off the + * main thread); null while the daemon broker isn't attached yet. + */ + public NativeSurfaceSource(@NonNull SurfaceView surfaceView, int guestWidth, int guestHeight, + @NonNull Supplier displayBinderSupplier, + @NonNull Handler mainHandler, @NonNull Callbacks callbacks) { + provider = new DisplayProvider(surfaceView, guestWidth, guestHeight, displayBinderSupplier, + connected -> mainHandler.post(() -> callbacks.onStateChanged( + connected ? State.CONNECTED : State.CONNECTING)), + config -> mainHandler.post(() -> callbacks.onContentSize(config.width, config.height))); + } + + /** + * Follow the guest cursor. Positions are GUEST framebuffer coordinates, delivered on the main + * thread, and arrive only on this display path -- the VNC path has no such channel. + * + * The caller decides what to do with them, and in particular whether to act at all: a cursor + * move caused by a remote desktop session is indistinguishable here from one the user made, + * so gating on "the user is currently driving the pointer" belongs to the consumer. + */ + @Override + public void setCursorView(android.view.SurfaceView cursorView) { + provider.setCursorView(cursorView); + } + + @Override + public void setCursorListener(java.util.function.BiConsumer listener) { + provider.setCursorListener(listener == null ? null : listener::accept); + } + + @Override + public void start() { + } + + @Override + public void shutdown() { + provider.shutdown(); + } + + @Override + public boolean supportsGuestResize() { + return false; + } + + @Override + public void requestGuestResize(int width, int height) { + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/VMNativeDisplayActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/VMNativeDisplayActivity.java index 12f0622f..b83398d0 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/VMNativeDisplayActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/display/VMNativeDisplayActivity.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.ui.vm.display.nativedisplay.display; import static android.view.Gravity.CENTER; @@ -7,16 +10,12 @@ import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.annotation.SuppressLint; -import android.content.BroadcastReceiver; import android.content.Context; -import android.content.Intent; -import android.content.IntentFilter; +import android.graphics.RectF; import android.graphics.drawable.GradientDrawable; import android.os.Bundle; import android.os.Handler; -import android.os.IBinder; import android.os.Looper; -import android.os.RemoteException; import android.util.Base64; import android.util.Log; import android.view.KeyCharacterMap; @@ -31,10 +30,15 @@ import android.widget.FrameLayout; import android.widget.LinearLayout; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; import com.google.android.material.appbar.MaterialToolbar; import com.google.android.material.button.MaterialButton; @@ -46,13 +50,29 @@ import cn.classfun.droidvm.R; import cn.classfun.droidvm.display.INativeDisplayRootService; +import cn.classfun.droidvm.DroidVMApp; import cn.classfun.droidvm.lib.daemon.DaemonConnection; +import cn.classfun.droidvm.lib.daemon.ForegroundCallback; import cn.classfun.droidvm.lib.store.vm.NativeDisplay; +import cn.classfun.droidvm.lib.store.vm.VMScreenConfig; import cn.classfun.droidvm.lib.ui.DragTouchListener; +import cn.classfun.droidvm.lib.ui.ImeInsetsExempt; import cn.classfun.droidvm.lib.ui.MaterialMenu; +import cn.classfun.droidvm.ui.vm.display.base.DaemonDisplayAttach; +import cn.classfun.droidvm.ui.vm.display.base.DisplayChromeController; import cn.classfun.droidvm.ui.vm.display.base.DisplayExtraKeysPanel; +import cn.classfun.droidvm.ui.vm.display.base.DisplayKeyboardMenuRow; +import cn.classfun.droidvm.ui.vm.display.base.KeyboardMode; +import cn.classfun.droidvm.ui.vm.display.base.DisplayPhysicalKeyboardView; +import cn.classfun.droidvm.ui.vm.display.base.DisplaySource; +import cn.classfun.droidvm.ui.vm.display.base.DisplayViewportController; +import cn.classfun.droidvm.ui.vm.display.base.InputMode; +import cn.classfun.droidvm.ui.vm.display.base.PointerGestureTranslator; +import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.EvdevEncoder; import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.DirectInputSink; import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.InputForwarder; +import cn.classfun.droidvm.lib.perf.GamePerfHint; +import cn.classfun.droidvm.lib.perf.SystemGestureGuard; import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.NativeExtraKeysPanel; import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.NativeKeyboardEditText; import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.TouchScaleCalculator; @@ -69,13 +89,26 @@ * The binder can't ride the daemon's TCP/JSON-RPC channel, so it arrives via a broadcast the daemon * sends in response to the {@code display_attach} request below. */ -public final class VMNativeDisplayActivity extends AppCompatActivity { +// ImeInsetsExempt: the display area handles the IME inset itself (root insets listener below); +// without the exemption the app-wide ImeInsetsApplier would pad the content view a second time. +public final class VMNativeDisplayActivity extends AppCompatActivity + implements ImeInsetsExempt, ForegroundCallback { private static final String TAG = "VMNativeDisplay"; public static final String EXTRA_VM_NAME = "vm_name"; public static final String EXTRA_VM_ID = "vm_id"; + /** + * Which screen this console shows. The display service name is built from it, so it has to + * come from whoever opened the console -- a VM can have two screens and only one of them is + * registered under the name this activity waits on. + */ + public static final String EXTRA_SCREEN = "screen"; + /** + * Whether that screen was configured with its own absolute input devices. Used only to say + * why touch is doing nothing; where the events go is the daemon's answer, not this one. + */ + public static final String EXTRA_INPUT_ENABLED = "input_enabled"; public static final String EXTRA_WIDTH = "display_width"; public static final String EXTRA_HEIGHT = "display_height"; - private static final long AUTO_HIDE_DELAY_MS = 3000; private final Handler mainHandler = new Handler(Looper.getMainLooper()); // Converts committed IME text into key events (handles Shift for upper-case/symbols). @@ -90,48 +123,149 @@ public final class VMNativeDisplayActivity extends AppCompatActivity { private TextView tvConnectingMessage; private FrameLayout displayContainer; private SurfaceView surfaceView; + private SurfaceView cursorView; + + // Last viewport transform, mirrored so the cursor overlay can be placed without asking the + // controller to recompute it. Written only by onViewportChanged, read only on the main thread. + private float vpBaseW, vpBaseH, vpViewScale = 1f, vpOffsetX, vpOffsetY; + + // True while a finger is on the container in relative-pointer mode. This is the gate for + // viewport follow: a cursor move from a REMOTE DESKTOP session is byte-identical to one the + // user made -- the position stream cannot tell them apart -- so only the input layer, which + // knows whether this device is currently driving, may decide to pan. + private boolean pointerDriveActive = false; + private int lastCursorX = -1; + private int lastCursorY = -1; private NativeKeyboardEditText keyboardInput; private FloatingActionButton fabMenu; private MaterialButton btnFullscreen; private DisplayExtraKeysPanel extraKeysPanel; + private DisplayPhysicalKeyboardView phyKeyboard; private String vmName = ""; private String vmId = ""; private String vmKey = ""; + /** + * The screen this console shows. It picks the display service to wait on, and it picks which + * screen's absolute input devices the touches land on -- the two devices are per screen, so + * "the VM's touchscreen" is not a thing that can be addressed any more. + */ + private String screenId = VMScreenConfig.ID_GPU0; + /** Whether that screen has absolute input devices at all; see {@link #EXTRA_INPUT_ENABLED}. */ + private boolean screenInputEnabled = true; private int guestWidth = 1280; private int guestHeight = 720; - private INativeDisplayRootService rootService; - private DisplayProvider displayProvider; + private DisplaySource displaySource; private InputForwarder inputForwarder; private DirectInputSink directSink; private NativeExtraKeysPanel nativeExtraKeys; - private boolean isFullscreen = false; private boolean connected = false; + // Pointer input mode, shared with the VNC path via the "display_input_mode" pref; applied to the + // InputForwarder so on-screen touches route to the multi-touch / mouse / tablet virtio device. + private InputMode inputMode = InputMode.TOUCH; + private static final String INPUT_PREFS = "droidvm_prefs"; + private static final String KEY_INPUT_MODE = "display_input_mode"; + // Chrome memory, shared with the VNC path: extra-keys on/off per typing surface + whether + // the physical keyboard is up. + private static final String KEY_KEYBOARD_MODE = "display_keyboard_mode"; + private static final String KEY_ZONE_EXTRA = "display_keyboard_zone_extra"; + private static final String KEY_ZONE_FNX = "display_keyboard_zone_fnx"; + // Unified MOUSE/TABLET gesture layer (two-finger tap = right click, two-finger pan = scroll, + // three-finger = local zoom/pan). TOUCH mode bypasses it and stays raw multi-touch. + private PointerGestureTranslator gestureTranslator; + // Fractional remainders of relative mouse motion so slow drags aren't rounded away. + private float mouseRemX, mouseRemY; + // Last mouse right/middle-button activity: any BACK arriving shortly after is the framework's + // (or OEM's) right-click fallback, regardless of what source it claims - swallow it. + private long lastMouseButtonMs; + private static final long MOUSE_BACK_SUPPRESS_MS = 800; + // Single sources of truth for viewport geometry (fit/zoom/pan across display-area changes) + // and chrome visibility (fullscreen / extra keys). See the controller classes for the rules. + private DisplayViewportController viewport; + private DisplayChromeController chrome; + // Display areas smaller than this (e.g. landscape with a tall IME) freeze the viewport + // instead of re-laying it out; see DisplayViewportController. + private static final int MIN_AREA_DP = 96; + /** Screen-space breathing room kept around the guest cursor when the view follows it. */ + private static final float CURSOR_FOLLOW_MARGIN_PX = 96f; + /** + * Where the cursor overlay goes while the guest's pointer is hidden. + * + * NOT setVisibility(GONE). A SurfaceView's Surface follows its visibility, so GONE destroys it + * and the app hands crosvm a removeSurface(cursor). The guest hides and re-shows the pointer + * all the time -- KDE drops to a software cursor whenever the hardware plane cannot keep up + * with a fast move, then hands the plane straight back -- and the image comes back in ONE + * UPDATE_CURSOR, which writes the position down the pipe and flushes the pixels in the same + * breath. Re-creating a Surface takes a frame plus a binder round trip, so those pixels land + * in the backend's sink buffer and are dropped; every later MOVE_CURSOR carries a position and + * no image, leaving the pointer blank until the guest happens to change its shape. Parking the + * view keeps the Surface alive, so the flush has somewhere to land. Far enough off-screen to + * be outside any panel, small enough to stay well clear of float/int overflow in the layer. + */ + private static final float CURSOR_PARKED_PX = -10000f; - // Per-attach random token: we only accept the binder broadcast carrying the nonce we requested, - // so another app spoofing the (exported) action can't slip us a fake broker binder. - private final String attachNonce = UUID.randomUUID().toString(); - private boolean binderReceiverRegistered = false; - private boolean rootConnected = false; - - // The daemon broadcasts its broker binder here in response to our display_attach request. - private final BroadcastReceiver binderReceiver = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - var bundle = intent.getBundleExtra(NativeDisplay.EXTRA_BUNDLE); - if (bundle == null || !attachNonce.equals(bundle.getString(NativeDisplay.EXTRA_NONCE))) - return; - var binder = bundle.getBinder(NativeDisplay.EXTRA_BINDER); - if (binder != null) onBinderReceived(binder); - } - }; + // Maps the unified gestures onto the crosvm --input evdev channels via InputForwarder. + private final PointerGestureTranslator.Listener gestureListener = + new PointerGestureTranslator.Listener() { + @Override + public void onRelativeMove(float dxGuest, float dyGuest) { + if (inputForwarder == null) return; + mouseRemX += dxGuest; + mouseRemY += dyGuest; + int dx = (int) mouseRemX, dy = (int) mouseRemY; + if (dx == 0 && dy == 0) return; + mouseRemX -= dx; + mouseRemY -= dy; + inputForwarder.sendMouseMove(dx, dy); + } + + @Override + public void onAbsoluteMove(float xGuest, float yGuest) { + if (inputForwarder != null) + inputForwarder.sendAbsMove(Math.round(xGuest), Math.round(yGuest)); + } + + @Override + public void onLeftButton(boolean down, float xGuest, float yGuest) { + if (inputForwarder == null) return; + if (inputMode == InputMode.TABLET) { + inputForwarder.sendAbsLeftButton(down, Math.round(xGuest), Math.round(yGuest)); + } else { + inputForwarder.sendPointerButton(EvdevEncoder.BTN_LEFT, down); + } + } + + @Override + public void onLeftTap(float xGuest, float yGuest) { + onLeftButton(true, xGuest, yGuest); + onLeftButton(false, xGuest, yGuest); + } - // Daemon died (e.g. restart): drop the broker binder so writes fall back to the vm_input IPC. - private final IBinder.DeathRecipient deathRecipient = () -> mainHandler.post(() -> { - Log.w(TAG, "daemon broker binder died"); - rootService = null; - }); + @Override + public void onRightClick(float xGuest, float yGuest) { + if (inputForwarder == null) return; + if (inputMode == InputMode.TABLET) + inputForwarder.sendAbsMove(Math.round(xGuest), Math.round(yGuest)); + inputForwarder.sendPointerButton(EvdevEncoder.BTN_RIGHT, true); + inputForwarder.sendPointerButton(EvdevEncoder.BTN_RIGHT, false); + } + + @Override + public void onScroll(int vNotches, int hNotches) { + if (inputForwarder != null) inputForwarder.sendScroll(vNotches, hNotches); + } + + @Override + public void onZoomPan(float scaleFactor, float dxView, float dyView, + float focusX, float focusY) { + if (viewport != null) viewport.onZoomPan(scaleFactor, dxView, dyView); + } + }; + + // Daemon broker binder acquisition (display_attach -> nonce-matched broadcast), shared with + // the VNC display path. + private DaemonDisplayAttach displayAttach; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { @@ -142,14 +276,18 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { var intent = getIntent(); vmName = orEmpty(intent.getStringExtra(EXTRA_VM_NAME)); vmId = orEmpty(intent.getStringExtra(EXTRA_VM_ID)); + screenId = orEmpty(intent.getStringExtra(EXTRA_SCREEN)); + if (screenId.isEmpty()) screenId = VMScreenConfig.ID_GPU0; + screenInputEnabled = intent.getBooleanExtra(EXTRA_INPUT_ENABLED, true); guestWidth = (int) intent.getLongExtra(EXTRA_WIDTH, 1280); guestHeight = (int) intent.getLongExtra(EXTRA_HEIGHT, 720); - vmKey = NativeDisplay.serviceNameFromId(vmId); + vmKey = NativeDisplay.serviceNameFromId(vmId, screenId); bindViews(); toolbar.setTitle(vmName.isEmpty() ? getString(R.string.native_display_title) : vmName); toolbar.setNavigationOnClickListener(v -> finish()); setupViews(); + setupLayoutControllers(); setStatus(getString(R.string.native_display_connecting), R.color.vnc_status_connecting); showOverlay(getString(R.string.native_display_waiting)); @@ -158,52 +296,30 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { showOverlay(getString(R.string.native_display_failed)); return; } - // Ask the daemon (uid=0) for its broker binder. It can't ride the JSON-RPC channel, so the - // daemon broadcasts it back to binderReceiver (matched by attachNonce). - registerReceiver(binderReceiver, - new IntentFilter(NativeDisplay.BINDER_BROADCAST_ACTION), Context.RECEIVER_EXPORTED); - binderReceiverRegistered = true; - requestDisplayBinder(10); - } - - // Sends display_attach; the daemon answers with a broadcast. Retries while the daemon connection - // is still coming up, since the Activity can open just before DaemonConnection authenticates. - private void requestDisplayBinder(int attemptsLeft) { - if (rootConnected || isFinishing()) return; - DaemonConnection.getInstance().buildRequest("display_attach") - .put("nonce", attachNonce) - .onError(e -> retryDisplayBinder(attemptsLeft)) - .onUnsuccessful(r -> retryDisplayBinder(attemptsLeft)) - .invoke(); - } - - private void retryDisplayBinder(int attemptsLeft) { - if (attemptsLeft <= 0) { - Log.w(TAG, "display_attach exhausted retries; daemon unavailable"); - return; - } - mainHandler.postDelayed(() -> requestDisplayBinder(attemptsLeft - 1), 500); - } + displayAttach = new DaemonDisplayAttach(this, mainHandler, + new DaemonDisplayAttach.Listener() { + @Override + public void onAttached(@NonNull INativeDisplayRootService service) { + onRootConnected(service); + } - private void onBinderReceived(@NonNull IBinder binder) { - if (rootConnected) return; // ignore duplicate broadcasts - rootConnected = true; - rootService = INativeDisplayRootService.Stub.asInterface(binder); - try { - binder.linkToDeath(deathRecipient, 0); - } catch (RemoteException e) { - Log.w(TAG, "linkToDeath failed", e); - } - onRootConnected(); + @Override + public void onLost() { + // DirectInputSink falls back to the vm_input RPC per write on a dead binder. + } + }); + displayAttach.start(); } - private void onRootConnected() { - if (rootService == null) return; + private void onRootConnected(@NonNull INativeDisplayRootService service) { // Try a direct unix-socket sink to the daemon (one write per evdev frame, no IPC // round-trip); on any failure it falls back to the vm_input JSON-RPC path below. - directSink = new DirectInputSink(vmId, rootService, this::sendInputToDaemon); + directSink = new DirectInputSink(vmId, () -> screenId, service, this::sendInputToDaemon); inputForwarder = new InputForwarder(directSink); if (nativeExtraKeys != null) nativeExtraKeys.setForwarder(inputForwarder); + // A forwarder is built fresh on every attach and starts in TOUCH, so it has to be told + // the mode this session is actually in (restored in setupViews, or since changed). + inputForwarder.setInputMode(inputMode); // Start the VM now that listeners are up (no-op if already running). DaemonConnection.getInstance().buildRequest("vm_start") @@ -214,10 +330,10 @@ private void onRootConnected() { .invoke(); // Display binder is looked up via the root service (servicemanager) on a bg thread. - displayProvider = new DisplayProvider( + displaySource = new NativeSurfaceSource( surfaceView, guestWidth, guestHeight, () -> { - var svc = rootService; + var svc = displayAttach.getService(); if (svc == null) return null; try { return svc.waitForDisplayBinder(vmKey); @@ -225,22 +341,40 @@ private void onRootConnected() { return null; } }, - isConnected -> mainHandler.post(() -> onDisplayConnected(isConnected)), - config -> mainHandler.post(() -> { - guestWidth = config.width; - guestHeight = config.height; - updateAspectRatio(displayContainer.getWidth(), displayContainer.getHeight()); - }) - ); - } - - private void onDisplayConnected(boolean isConnected) { - connected = isConnected; - if (isConnected) { + mainHandler, + new DisplaySource.Callbacks() { + @Override + public void onContentSize(int width, int height) { + guestWidth = width; + guestHeight = height; + viewport.setContentSize(width, height); + // Keep the status-bar resolution label in step with a live resize. + if (connected) + setStatus(fmt(getString(R.string.native_display_connected), + guestWidth, guestHeight), R.color.vnc_status_connected); + } + + @Override + public void onStateChanged(@NonNull DisplaySource.State state) { + onDisplayStateChanged(state); + } + }); + // Hardware cursor: give crosvm a Surface for the guest's cursor plane and follow the + // positions it reports. Both are no-ops on a guest that never uses the cursor plane. + // This must run here, on the main thread with displaySource assigned -- it used to sit + // inside the binder-supplier lambda above, where the first invocation raced the field + // assignment on a background thread and the cursor layer was wired only by luck. + displaySource.setCursorView(cursorView); + displaySource.setCursorListener(this::onGuestCursorMoved); + displaySource.start(); + } + + private void onDisplayStateChanged(@NonNull DisplaySource.State state) { + connected = state == DisplaySource.State.CONNECTED; + if (connected) { setStatus(fmt(getString(R.string.native_display_connected), guestWidth, guestHeight), R.color.vnc_status_connected); hideOverlay(); - updateAspectRatio(displayContainer.getWidth(), displayContainer.getHeight()); } else { setStatus(getString(R.string.native_display_connecting), R.color.vnc_status_connecting); showOverlay(getString(R.string.native_display_waiting)); @@ -255,24 +389,91 @@ private void bindViews() { overlayConnecting = findViewById(R.id.overlay_connecting); tvConnectingMessage = findViewById(R.id.tv_connecting_message); displayContainer = findViewById(R.id.display_container); + cursorView = findViewById(R.id.cursor_view); + // Visible from the start, parked off-screen: the Surface exists before the guest's first + // UPDATE_CURSOR, which is the only message that carries pointer pixels. Laid out GONE, the + // first pointer image was flushed into a Surface that did not exist yet and the pointer + // stayed blank until the next shape change -- the same hole the hide path used to open. + cursorView.setVisibility(VISIBLE); + parkCursorOverlay(); surfaceView = findViewById(R.id.surface_view); keyboardInput = findViewById(R.id.keyboard_input); fabMenu = findViewById(R.id.fab_menu); btnFullscreen = findViewById(R.id.btn_fullscreen); extraKeysPanel = findViewById(R.id.extra_keys_panel); nativeExtraKeys = new NativeExtraKeysPanel(extraKeysPanel); + phyKeyboard = findViewById(R.id.phy_keyboard); + phyKeyboard.setKeyListener(nativeExtraKeys); + // The physical keyboard's Shift/Ctrl/Alt/Win mirror the panel's sticky-modifier state. + extraKeysPanel.setModifierStateObserver(() -> phyKeyboard.refreshModifiers( + extraKeysPanel.isCtrlDown(), extraKeysPanel.isAltDown(), + extraKeysPanel.isShiftDown(), extraKeysPanel.isWinDown())); + extraKeysPanel.setZoneListener(new DisplayExtraKeysPanel.ZoneListener() { + @Override + public void onToggleFnxZone() { + chrome.toggleFnxZone(); + } + + @Override + public void onShowSystemKeyboard() { + toggleSoftKeyboard(); + } + }); + phyKeyboard.setZoneListener(new DisplayPhysicalKeyboardView.ZoneListener() { + @Override + public void onToggleExtraZone() { + chrome.toggleExtraZone(); + } + + @Override + public void onToggleFnxZone() { + chrome.toggleFnxZone(); + } + + @Override + public void onCloseKeyboard() { + chrome.setKeyboardMode(KeyboardMode.NONE); + } + }); } @SuppressLint("ClickableViewAccessibility") private void setupViews() { btnFullscreen.setOnClickListener(v -> toggleFullscreen()); + // The container's layout size IS the display area: chrome visibility, IME and rotation + // all funnel into it through normal layout. The viewport handles degenerate sizes itself. displayContainer.addOnLayoutChangeListener(( v, l, t, r, b, ol, ot, or2, ob ) -> { int cw = r - l, ch = b - t; - if (cw > 0 && ch > 0) v.post(() -> updateAspectRatio(cw, ch)); + v.post(() -> viewport.setArea(cw, ch)); }); surfaceView.setOnTouchListener(this::onSurfaceTouch); + // MOUSE/TABLET gestures live on the container: the whole display area (letterbox included) + // is gesture surface; the translator pins coordinate ops to the rendered surface rect. + // In TOUCH mode this listener declines and raw multi-touch stays on the surface view. + displayContainer.setOnTouchListener(this::onContainerTouch); + // Host mouse/stylus: scroll wheel + right/middle buttons come as generic-motion events, + // hover comes as hover events; both feed the pointer device so right-click/scroll/hover + // pass through to the guest (tablet mode gives absolute hover). + surfaceView.setOnGenericMotionListener(this::onSurfaceGenericMotion); + // Also on the container: a right-click over the letterbox area (outside the surface) must + // still be consumed or the framework synthesizes BACK from it. + displayContainer.setOnGenericMotionListener(this::onSurfaceGenericMotion); + surfaceView.setOnHoverListener(this::onSurfaceHover); + // Restore the persisted mode here rather than on the daemon attach: the FAB menu is + // reachable before the binder arrives, and a menu built on a stale TOUCH would write that + // back over the stored mode. + inputMode = InputMode.fromOrdinal( + getSharedPreferences(INPUT_PREFS, MODE_PRIVATE).getInt(KEY_INPUT_MODE, 0)); + gestureTranslator = new PointerGestureTranslator(mainHandler, gestureListener); + gestureTranslator.setAbsolute(inputMode == InputMode.TABLET); + // Keep the display area out of the system-gesture zones so multi-finger gestures + // (two-finger right-click/scroll, three-finger zoom) don't trip OEM gestures like + // three-finger screenshot or edge-back. + displayContainer.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or2, ob) -> + v.setSystemGestureExclusionRects( + java.util.Collections.singletonList(new android.graphics.Rect(0, 0, r - l, b - t)))); keyboardInput.setTextInputListener(new NativeKeyboardEditText.TextInputListener() { @Override public void onCommitText(@NonNull CharSequence text) { @@ -290,18 +491,26 @@ public void onDeleteSurrounding(int beforeLength, int afterLength) { } // Soft keyboards that commit text (instead of sending key events) land here; translate each - // character to its key event sequence and forward as evdev. + // character to its evdev key sequence and forward it. Each char is resolved on its own so one + // unrepresentable character can't drop the whole commit. Uppercase letters and shifted symbols + // go through the deterministic US-layout table (Shift synthesized around the key); anything it + // doesn't cover falls back to the framework key character map. private void forwardText(@NonNull CharSequence text) { if (inputForwarder == null || !connected) return; - KeyEvent[] events = keyCharacterMap.getEvents(text.toString().toCharArray()); - if (events == null) return; // Wrap with the extra-keys panel modifiers so e.g. Ctrl+(typed key) reaches the guest. nativeExtraKeys.applyModifiers(true); - for (KeyEvent e : events) { - if (e.getAction() == KeyEvent.ACTION_DOWN) { - inputForwarder.sendKeyEvent(e.getKeyCode(), true); - } else if (e.getAction() == KeyEvent.ACTION_UP) { - inputForwarder.sendKeyEvent(e.getKeyCode(), false); + String s = text.toString(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (inputForwarder.sendChar(c)) continue; + KeyEvent[] events = keyCharacterMap.getEvents(new char[]{c}); + if (events == null) continue; + for (KeyEvent e : events) { + if (e.getAction() == KeyEvent.ACTION_DOWN) { + inputForwarder.sendKeyEvent(e.getKeyCode(), true); + } else if (e.getAction() == KeyEvent.ACTION_UP) { + inputForwarder.sendKeyEvent(e.getKeyCode(), false); + } } } nativeExtraKeys.applyModifiers(false); @@ -315,15 +524,199 @@ private void tapKey(int keyCode) { nativeExtraKeys.applyModifiers(false); } + // TOUCH mode only: raw multi-touch stays on the surface view, which is sized to the guest + // aspect ratio, so offsets are zero and view coords normalize straight to the multi-touch + // device's fixed ABS range. (Touch coords stay in view-local space even when the three-finger + // zoom transform is applied - Android inverse-maps them.) MOUSE/TABLET decline here so the + // event bubbles up to the container. private boolean onSurfaceTouch(View v, MotionEvent event) { + if (inputMode != InputMode.TOUCH) return false; if (inputForwarder == null || v.getWidth() <= 0 || v.getHeight() <= 0) return false; - // The SurfaceView is sized to the guest aspect ratio, so offsets are zero and scale is - // simply guest/view per axis. - var tf = TouchScaleCalculator.compute(guestWidth, guestHeight, v.getWidth(), v.getHeight()); + if (isMouseButtonTouch(event)) return true; + var tf = TouchScaleCalculator.compute(v.getWidth(), v.getHeight()); inputForwarder.sendTouchEvent(event, tf.scaleX, tf.scaleY); return true; } + // MOUSE/TABLET gesture surface: the whole container is active; the translator pins gestures + // that carry guest coordinates to the rendered surface rect. + private boolean onContainerTouch(View v, MotionEvent event) { + if (inputMode == InputMode.TOUCH) return false; + if (inputForwarder == null || gestureTranslator == null) return false; + if (isMouseButtonTouch(event)) return true; + // TABLET absolute coords go to the normalized-range evdev absolute-mouse device; MOUSE + // REL deltas are guest px, so they scale against the guest resolution instead. + float unitW = inputMode == InputMode.TABLET + ? EvdevEncoder.NORMALIZED_ABS_MAX : guestWidth; + float unitH = inputMode == InputMode.TABLET + ? EvdevEncoder.NORMALIZED_ABS_MAX : guestHeight; + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + pointerDriveActive = true; + break; + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: + pointerDriveActive = false; + break; + default: + break; + } + return gestureTranslator.onTouchEvent(event, displayRectInContainer(), unitW, unitH); + } + + /** + * The guest moved its hardware cursor. Places the overlay and, only while the user is actually + * driving the pointer with a finger in relative mode, pans a zoomed view to keep it visible. + * + * The gate is deliberately narrow. In relative mode a short drag can send the guest pointer a + * long way, so it can leave a zoomed viewport without the finger ever nearing the screen edge + * -- that is the case worth following. A remote desktop session driving the same guest cursor + * must NOT drag this device's view around under someone else's hand. + */ + private void onGuestCursorMoved(int gx, int gy) { + // crosvm sends u32::MAX,MAX when the guest hides its pointer (UPDATE_CURSOR resource_id=0, + // which is what switching to a text console does). Without acting on it the overlay keeps + // showing the last cursor image on a console that should have none. A real position is a + // framebuffer coordinate, so this value can never be a genuine one. + if (gx == -1 && gy == -1) { // u32::MAX arrives as -1 in Java's signed int + lastCursorX = -1; + lastCursorY = -1; + parkCursorOverlay(); + return; + } + lastCursorX = gx; + lastCursorY = gy; + positionCursorOverlay(); + if (pointerDriveActive && inputMode == InputMode.MOUSE && viewport != null) { + viewport.panToShowContentPoint(gx, gy, CURSOR_FOLLOW_MARGIN_PX); + } + } + + /** + * Take the pointer off screen without letting go of its Surface. See {@link #CURSOR_PARKED_PX}. + */ + private void parkCursorOverlay() { + if (cursorView == null) { + return; + } + cursorView.setTranslationX(CURSOR_PARKED_PX); + cursorView.setTranslationY(CURSOR_PARKED_PX); + } + + /** + * Put the 64x64 cursor overlay where the guest says its pointer is. + * + * Same transform the scanout gets: content is centred in the container, scaled about its + * centre, then displaced by the pan offset. The overlay is scaled too -- the guest's cursor is + * in guest pixels, so at 2x zoom it has to double like everything else, or the pointer shrinks + * relative to what it is pointing at. Pivot goes to (0,0) so scaling grows the image away from + * the hotspot corner instead of around its middle. + */ + private void positionCursorOverlay() { + if (cursorView == null || vpBaseW <= 0 || guestWidth <= 0 || guestHeight <= 0) { + return; + } + if (lastCursorX < 0) { + return; // no position yet + } + View area = (View) surfaceView.getParent(); + if (area == null || area.getWidth() <= 0) { + return; + } + float vx = lastCursorX * vpBaseW / guestWidth; + float vy = lastCursorY * vpBaseH / guestHeight; + float cx = area.getWidth() / 2f + vpOffsetX + (vx - vpBaseW / 2f) * vpViewScale; + float cy = area.getHeight() / 2f + vpOffsetY + (vy - vpBaseH / 2f) * vpViewScale; + float pxPerGuestPx = (vpBaseW / (float) guestWidth) * vpViewScale; + + cursorView.setPivotX(0f); + cursorView.setPivotY(0f); + cursorView.setScaleX(pxPerGuestPx); + cursorView.setScaleY(pxPerGuestPx); + cursorView.setTranslationX(cx); + cursorView.setTranslationY(cy); + if (cursorView.getVisibility() != VISIBLE) { + cursorView.setVisibility(VISIBLE); + } + } + + // A hardware-mouse right/middle press also arrives on the touch stream (ACTION_DOWN with the + // button in buttonState). Those are delivered by the generic-motion handler; keep them out of + // the tap/gesture path (else right-click doubles as a left tap) but consume them so the + // framework doesn't synthesize a BACK key from an unhandled right-click. + private boolean isMouseButtonTouch(@NonNull MotionEvent event) { + if ((event.getSource() & android.view.InputDevice.SOURCE_MOUSE) != 0 + && (event.getButtonState() & (MotionEvent.BUTTON_SECONDARY + | MotionEvent.BUTTON_TERTIARY | MotionEvent.BUTTON_STYLUS_PRIMARY)) != 0) { + lastMouseButtonMs = android.os.SystemClock.uptimeMillis(); + return true; + } + return false; + } + + // Where the guest frame is rendered, in container coordinates: the letterbox-fitted surface + // bounds mapped through the viewport's current zoom/pan transform. + @NonNull + private RectF displayRectInContainer() { + var rect = new RectF(0, 0, surfaceView.getWidth(), surfaceView.getHeight()); + surfaceView.getMatrix().mapRect(rect); + rect.offset(surfaceView.getLeft(), surfaceView.getTop()); + return rect; + } + + // Host mouse/stylus scroll wheel and right/middle buttons (left stays on the touch/tap path). + // Button presses are ALWAYS consumed - an unhandled BUTTON_SECONDARY press is what makes the + // framework synthesize a BACK key, which must never fire inside the VM display. + private boolean onSurfaceGenericMotion(View v, MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_SCROLL: + if (inputForwarder != null) { + inputForwarder.sendScroll( + Math.round(event.getAxisValue(MotionEvent.AXIS_VSCROLL)), + Math.round(event.getAxisValue(MotionEvent.AXIS_HSCROLL))); + } + return true; + case MotionEvent.ACTION_BUTTON_PRESS: + case MotionEvent.ACTION_BUTTON_RELEASE: { + lastMouseButtonMs = android.os.SystemClock.uptimeMillis(); + short btn = mapActionButton(event.getActionButton()); + if (btn != 0 && inputForwarder != null) { + inputForwarder.sendPointerButton(btn, + event.getActionMasked() == MotionEvent.ACTION_BUTTON_PRESS); + } + return true; + } + default: + return false; + } + } + + // Host pointer hover (no button): TABLET mode only - absolute hover on the guest tablet. + // MOUSE/TOUCH modes deliberately ignore Android-side hover. + private boolean onSurfaceHover(View v, MotionEvent event) { + if (inputForwarder == null || v.getWidth() <= 0 || v.getHeight() <= 0) return false; + if (inputMode != InputMode.TABLET) return false; + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_HOVER_MOVE || action == MotionEvent.ACTION_HOVER_ENTER) { + var tf = TouchScaleCalculator.compute(v.getWidth(), v.getHeight()); + inputForwarder.sendHover(event.getX(), event.getY(), tf.scaleX, tf.scaleY); + return true; + } + return false; + } + + private static short mapActionButton(int actionButton) { + switch (actionButton) { + case MotionEvent.BUTTON_SECONDARY: + case MotionEvent.BUTTON_STYLUS_PRIMARY: + return EvdevEncoder.BTN_RIGHT; + case MotionEvent.BUTTON_TERTIARY: + return EvdevEncoder.BTN_MIDDLE; + default: + return 0; + } + } + // Sink for InputForwarder: ships encoded evdev to the daemon, which owns the crosvm input // sockets and writes them to the guest. Called on the (single) InputForwarder worker thread, so // the synchronous request keeps events ordered and back-pressured. @@ -332,6 +725,7 @@ private boolean sendInputToDaemon(int channel, @NonNull byte[] data) { var req = new JSONObject(); req.put("command", "vm_input"); req.put("vm_id", vmId); + req.put("screen", screenId); req.put("channel", channel); req.put("data", Base64.encodeToString(data, Base64.NO_WRAP)); var resp = DaemonConnection.getInstance().request(req); @@ -344,6 +738,15 @@ private boolean sendInputToDaemon(int channel, @NonNull byte[] data) { @Override public boolean dispatchKeyEvent(@NonNull KeyEvent event) { int keyCode = event.getKeyCode(); + // A hardware-mouse right-click the framework (or OEM ROM) failed to see consumed gets + // synthesized as a BACK key - sometimes mouse-sourced, sometimes (OEM injection) claiming + // a keyboard/virtual source. Swallow BACK when it's mouse-sourced OR arrives right after + // any mouse right/middle-button activity; the click itself already went to the guest. + if (keyCode == KeyEvent.KEYCODE_BACK + && ((event.getSource() & android.view.InputDevice.SOURCE_MOUSE) != 0 + || android.os.SystemClock.uptimeMillis() - lastMouseButtonMs + < MOUSE_BACK_SUPPRESS_MS)) + return true; if (keyCode == KeyEvent.KEYCODE_VOLUME_UP || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) return super.dispatchKeyEvent(event); if (inputForwarder != null && connected) { @@ -383,19 +786,83 @@ private static boolean isModifierKey(int keyCode) { } } - private void updateAspectRatio(int containerW, int containerH) { - if (containerW <= 0 || containerH <= 0 || guestWidth <= 0 || guestHeight <= 0) return; - float vmAspect = (float) guestWidth / guestHeight; - float containerAspect = (float) containerW / containerH; - int viewW, viewH; - if (vmAspect > containerAspect) { - viewW = containerW; - viewH = Math.round(containerW / vmAspect); - } else { - viewH = containerH; - viewW = Math.round(containerH * vmAspect); - } - surfaceView.setLayoutParams(new FrameLayout.LayoutParams(viewW, viewH, CENTER)); + // Wires the viewport controller (single writer of the SurfaceView geometry), the chrome + // controller (single writer of toolbar/status bar/extra keys/system bars visibility) and the + // window-insets listener that turns system bars + IME into root padding, which in turn sizes + // the display container. + private void setupLayoutControllers() { + int minAreaPx = Math.round(MIN_AREA_DP * getResources().getDisplayMetrics().density); + viewport = new DisplayViewportController(minAreaPx, + new DisplayViewportController.Listener() { + @Override + public void onViewportChanged(int baseW, int baseH, float viewScale, + float offsetX, float offsetY) { + surfaceView.setLayoutParams(new FrameLayout.LayoutParams(baseW, baseH, CENTER)); + surfaceView.setScaleX(viewScale); + surfaceView.setScaleY(viewScale); + surfaceView.setTranslationX(offsetX); + surfaceView.setTranslationY(offsetY); + vpBaseW = baseW; + vpBaseH = baseH; + vpViewScale = viewScale; + vpOffsetX = offsetX; + vpOffsetY = offsetY; + positionCursorOverlay(); + } + + @Override + public void onGuestResizeWanted(int areaW, int areaH) { + // Auto-resize Guest Display: no guest-side channel on this path yet. + } + }); + viewport.setContentSize(guestWidth, guestHeight); + + var inputPrefs = getSharedPreferences(INPUT_PREFS, MODE_PRIVATE); + chrome = new DisplayChromeController( + KeyboardMode.fromName(inputPrefs.getString(KEY_KEYBOARD_MODE, null)), + inputPrefs.getBoolean(KEY_ZONE_EXTRA, true), + inputPrefs.getBoolean(KEY_ZONE_FNX, false), + (fullscreen, mode, extraVisible, fnxVisible) -> { + toolbar.setVisibility(fullscreen ? GONE : VISIBLE); + statusBar.setVisibility(fullscreen ? GONE : VISIBLE); + extraKeysPanel.applyZones( + extraVisible, fnxVisible, mode == KeyboardMode.SYSTEM); + phyKeyboard.setZoneToggleState(extraVisible, fnxVisible); + phyKeyboard.setVisibleAnimated(mode == KeyboardMode.LAPTOP); + var controller = getWindow().getInsetsController(); + if (controller != null) { + if (fullscreen) { + controller.hide(WindowInsets.Type.systemBars()); + controller.setSystemBarsBehavior(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } else { + controller.show(WindowInsets.Type.systemBars()); + } + } + // Re-request insets so the root padding (and thus the display area) updates in the + // same pass as the visibility changes. + ViewCompat.requestApplyInsets(findViewById(R.id.main)); + }); + chrome.setStateListener((mode, extraVisible, fnxVisible) -> inputPrefs.edit() + .putString(KEY_KEYBOARD_MODE, mode.name()) + .putBoolean(KEY_ZONE_EXTRA, extraVisible) + .putBoolean(KEY_ZONE_FNX, fnxVisible) + .apply()); + chrome.applyInitial(); + + View root = findViewById(R.id.main); + ViewCompat.setOnApplyWindowInsetsListener(root, (v, insets) -> { + Insets sysBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); + Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime()); + // The Main row's shared slot follows the IME: Fn while it is up, "show IME" while + // it is down. The system owns that visibility, so read it rather than track it. + extraKeysPanel.setImeVisible(insets.isVisible(WindowInsetsCompat.Type.ime())); + boolean fullscreen = chrome != null && chrome.isFullscreen(); + int top = fullscreen ? 0 : sysBars.top; + int bottom = Math.max(fullscreen ? 0 : sysBars.bottom, ime.bottom); + v.setPadding(0, top, 0, bottom); + return insets; + }); + ViewCompat.requestApplyInsets(root); } private void setStatus(String text, int colorRes) { @@ -417,69 +884,205 @@ private void hideOverlay() { private void toggleSoftKeyboard() { var imm = getSystemService(InputMethodManager.class); - if (imm != null) tryShowKeyboard(imm, 10); + if (imm == null) return; + // Post so the fab-menu popup has finished tearing down: it still owns the touch-driven + // focus transition synchronously after the item click, so requesting focus + showing the + // IME inline lands before our editor is the served view and does nothing. + mainHandler.post(() -> tryShowKeyboard(imm, 15)); } // Drive a real (invisible) EditText: a SurfaceView is an unreliable IME target on some ROMs. - // The popup menu relinquishes window focus right before this runs, so the editor isn't yet - // "served" by the IMM and showSoftInput() is silently ignored (no ResultReceiver callback - // either). Retry on a short delay until the input connection is established; force as a last - // resort. + // showSoftInput() can return true for a view the IMM isn't serving yet and show nothing, so the + // success test is imm.isActive(editor), retried on a short delay until the input connection is + // live. The last few rounds force the IME (some ROMs ignore the implicit request). private void tryShowKeyboard(@NonNull InputMethodManager imm, int attemptsLeft) { + if (attemptsLeft <= 0 || isFinishing()) return; + keyboardInput.requestFocusFromTouch(); keyboardInput.requestFocus(); - if (imm.showSoftInput(keyboardInput, 0)) return; - if (attemptsLeft > 0) { - mainHandler.postDelayed(() -> tryShowKeyboard(imm, attemptsLeft - 1), 60); - } + int flag = attemptsLeft <= 3 + ? InputMethodManager.SHOW_FORCED : InputMethodManager.SHOW_IMPLICIT; + imm.showSoftInput(keyboardInput, flag); + if (keyboardInput.isFocused() && imm.isActive(keyboardInput)) return; + mainHandler.postDelayed(() -> tryShowKeyboard(imm, attemptsLeft - 1), 60); } private void toggleFullscreen() { - isFullscreen = !isFullscreen; - var controller = getWindow().getInsetsController(); - if (controller == null) return; - if (isFullscreen) { - toolbar.setVisibility(GONE); - statusBar.setVisibility(GONE); - extraKeysPanel.setVisibility(GONE); - controller.hide(WindowInsets.Type.systemBars()); - controller.setSystemBarsBehavior(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); - } else { - toolbar.setVisibility(VISIBLE); - statusBar.setVisibility(VISIBLE); - extraKeysPanel.animateIn(); - controller.show(WindowInsets.Type.systemBars()); - } + chrome.toggleFullscreen(); } private void showFabMenu() { var popup = new MaterialMenu(this, fabMenu); popup.inflate(R.menu.menu_native_display_menu); + var header = new LinearLayout(this); + header.setOrientation(LinearLayout.VERTICAL); + header.addView(buildInputModeHeader(popup)); + header.addView(DisplayKeyboardMenuRow.build( + getLayoutInflater(), chrome.getKeyboardMode(), this::applyKeyboardMode, + popup::dismiss)); + popup.setHeaderView(header); popup.setOnMenuItemClickListener(this::onMenuItemClicked); popup.show(); } + // Menu header: one row of three icon buttons (touch / tablet / mouse), active mode checked. + private View buildInputModeHeader(MaterialMenu popup) { + var group = (com.google.android.material.button.MaterialButtonToggleGroup) + getLayoutInflater().inflate(R.layout.view_input_mode_toggle, null); + group.check(inputMode == InputMode.MOUSE ? R.id.mode_mouse + : inputMode == InputMode.TABLET ? R.id.mode_tablet : R.id.mode_touch); + group.addOnButtonCheckedListener((g, checkedId, isChecked) -> { + if (!isChecked) return; + setInputModeTo(checkedId == R.id.mode_mouse ? InputMode.MOUSE + : checkedId == R.id.mode_tablet ? InputMode.TABLET : InputMode.TOUCH); + popup.dismiss(); + }); + return group; + } + + // Selecting the system keyboard summons the IME; anything else puts it away, so the mode + // and what is actually on screen agree. + private void applyKeyboardMode(@NonNull KeyboardMode mode) { + chrome.setKeyboardMode(mode); + if (mode == KeyboardMode.SYSTEM) toggleSoftKeyboard(); + else hideSoftKeyboard(); + } + + // Dropping the editor's focus first matters: it is what the IME is attached to, and some + // ROMs re-show the keyboard for a still-focused editor right after a hide request. + private void hideSoftKeyboard() { + keyboardInput.clearFocus(); + var controller = WindowCompat.getInsetsController(getWindow(), keyboardInput); + controller.hide(WindowInsetsCompat.Type.ime()); + var imm = getSystemService(InputMethodManager.class); + if (imm != null) + imm.hideSoftInputFromWindow(findViewById(R.id.main).getWindowToken(), 0); + } + private boolean onMenuItemClicked(@NonNull MenuItem item) { int id = item.getItemId(); - if (id == R.id.menu_keyboard) { - toggleSoftKeyboard(); - return true; - } else if (id == R.id.menu_extra_keys) { - extraKeysPanel.setVisibleAnimated(extraKeysPanel.getVisibility() != VISIBLE); - return true; - } else if (id == R.id.menu_fullscreen) { + if (id == R.id.menu_fullscreen) { toggleFullscreen(); return true; + } else if (id == R.id.menu_rotate) { + toggleOrientation(); + return true; } return false; } + // Select TOUCH/MOUSE/TABLET: persist (shared with VNC) and route the InputForwarder + gesture + // translator to the matching virtio-input device. The segmented header shows the active mode. + private void setInputModeTo(@NonNull InputMode mode) { + if (inputMode == mode) return; + // Both absolute modes ride this screen's own devices, so with them switched off the mode + // is selectable and inert. Say so once, here, rather than leaving the user tapping a + // screen that answers nothing -- and say the true reason, which is a VM that has to be + // started again, not a setting that would take effect if they waited. + // + // The switch reaches typing too now, since the keyboard became this screen's rather than + // the VM's. The hint names no device, so it stays true of all three; MOUSE is still the + // one thing the switch does not touch, the relative pointer being the VM's. + if (!screenInputEnabled && mode != InputMode.MOUSE) + Toast.makeText(this, R.string.display_input_disabled_hint, Toast.LENGTH_LONG).show(); + inputMode = mode; + getSharedPreferences(INPUT_PREFS, MODE_PRIVATE).edit() + .putInt(KEY_INPUT_MODE, inputMode.ordinal()).apply(); + if (inputForwarder != null) inputForwarder.setInputMode(inputMode); + if (gestureTranslator != null) { + gestureTranslator.setAbsolute(inputMode == InputMode.TABLET); + gestureTranslator.reset(); + } + } + + private void toggleOrientation() { + boolean landscape = getResources().getConfiguration().orientation + == android.content.res.Configuration.ORIENTATION_LANDSCAPE; + setRequestedOrientation(landscape + ? android.content.pm.ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + : android.content.pm.ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + } + + /** + * Registry key for the VM-event callback, unique per instance. + * + * Not the tag: this activity is recreated (the orientation flip on entry does it), and the + * incoming instance's onStart() runs before the outgoing one's onStop() -- so a key shared by + * both has the one leaving unregister the one arriving, and the console then never hears that + * its VM exited. + */ + private final String eventKey = + fmt("%s@%s", TAG, Integer.toHexString(System.identityHashCode(this))); + + @Override + protected void onStart() { + super.onStart(); + var handler = ((DroidVMApp) getApplication()).getVMEventHandler(); + if (handler != null) handler.addForegroundCallback(eventKey, this); + } + + @Override + protected void onStop() { + super.onStop(); + var handler = ((DroidVMApp) getApplication()).getVMEventHandler(); + if (handler != null) handler.removeForegroundCallback(eventKey); + } + + /** + * The VM this console is attached to has gone. Close with it: what is left otherwise is a + * console that cannot reconnect, retrying a display service that will never be registered + * again (which used to cost the daemon dearly -- see NativeDisplayBinder). + * + * This is the exit event, not the absence of a crosvm process, and the difference is the + * point: a VM waiting for the huge-page reserve -- after a start, or between a guest reboot + * and its relaunch -- has no process either, and must not be mistaken for one that is gone. + * The daemon fires "rebooting" for that case and holds the exit event back until the VM + * really is not coming back, so there is nothing to second-guess here. + * + * A VM that crashed is the one case worth staying open for. VMEventHandler answers a non-zero + * exit by putting up the exit dialog -- the tail of the log, and a way into the full one -- + * on whatever activity is in front, and closing that activity out from under it would take + * the explanation with it. So the console holds; the user closes it after reading. The toast + * is that handler's job either way, so there is none here. + */ + @Override + public void onVMExited(UUID id, String vmName, int exitCode, JSONObject data) { + if (id == null || !id.toString().equals(vmId)) return; + if (exitCode != 0) { + Log.i(TAG, fmt("VM exited with %d -- keeping the console up for the exit dialog", + exitCode)); + return; + } + mainHandler.post(() -> { + if (isFinishing()) return; + Log.i(TAG, "VM stopped; closing the display"); + finish(); + }); + } + + @Override + protected void onResume() { + super.onResume(); + // A VM display is on screen and rendering: tell the platform this is sustained heavy + // gameplay so its power policy raises clocks (see GamePerfHint). + GamePerfHint.enterGameplay(this); + // And keep the host's full-screen touch gestures (OEM three-finger screenshot etc.) + // from eating multi-finger input meant for the guest (see SystemGestureGuard). + SystemGestureGuard.enterDisplay(); + } + + @Override + protected void onPause() { + super.onPause(); + GamePerfHint.exitGameplay(this); + SystemGestureGuard.exitDisplay(); + } + @Override protected void onDestroy() { super.onDestroy(); - extraKeysPanel.stopKeyRepeat(); - if (displayProvider != null) { - displayProvider.shutdown(); - displayProvider = null; + if (displaySource != null) { + displaySource.shutdown(); + displaySource = null; } if (inputForwarder != null) { inputForwarder.close(); @@ -489,20 +1092,10 @@ protected void onDestroy() { directSink.close(); directSink = null; } - if (binderReceiverRegistered) { - try { - unregisterReceiver(binderReceiver); - } catch (Exception ignored) { - } - binderReceiverRegistered = false; - } - if (rootService != null) { - try { - rootService.asBinder().unlinkToDeath(deathRecipient, 0); - } catch (Exception ignored) { - } + if (displayAttach != null) { + displayAttach.stop(); + displayAttach = null; } - rootService = null; } @NonNull diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/DirectInputSink.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/DirectInputSink.java index 523c758a..a7be181e 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/DirectInputSink.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/DirectInputSink.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.ui.vm.display.nativedisplay.input; import static cn.classfun.droidvm.lib.store.vm.NativeDisplay.CHANNEL_COUNT; @@ -8,6 +11,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.function.Supplier; + import cn.classfun.droidvm.display.INativeDisplayRootService; /** @@ -17,11 +22,17 @@ * {@code connectto} that su-domain socket nor receive its fd over binder, but it can hand the bytes * across binder. Falls back to {@code fallback} on any failure so the feature degrades to the * original IPC path instead of dropping input. + * + *

    Every write carries the screen the owning console is showing, because the absolute devices + * are per screen. The screen is read from the console rather than copied in at construction: a + * VNC console opened without one named learns which screen it got only when the daemon answers + * {@code vm_vnc_info}, and that answer can arrive after the broker binder does.

    */ public final class DirectInputSink implements InputForwarder.InputSink { private static final String TAG = "DirectInputSink"; private final String vmId; + private final Supplier screenId; private final INativeDisplayRootService rootService; private final InputForwarder.InputSink fallback; private volatile boolean closed = false; @@ -31,12 +42,19 @@ public final class DirectInputSink implements InputForwarder.InputSink { /** * @param vmId the VM id, used by the daemon to find the running VM's input channel. + * @param screenId the screen this console is currently showing; picks between two screens' + * absolute devices and their keyboards, and is ignored by the VM-wide + * relative pointer alone. Sent on every channel, so which channels are per + * screen stays {@link cn.classfun.droidvm.lib.store.vm.NativeDisplay}'s + * decision rather than something this side has to be taught. * @param rootService daemon broker binder that writes the bytes; null disables the direct path. * @param fallback sink used when the direct path is unavailable or errors. */ - public DirectInputSink(@NonNull String vmId, @Nullable INativeDisplayRootService rootService, + public DirectInputSink(@NonNull String vmId, @NonNull Supplier screenId, + @Nullable INativeDisplayRootService rootService, @Nullable InputForwarder.InputSink fallback) { this.vmId = vmId; + this.screenId = screenId; this.rootService = rootService; this.fallback = fallback; } @@ -46,7 +64,8 @@ public boolean write(int channel, @NonNull byte[] data) { if (closed || channel < 0 || channel >= CHANNEL_COUNT) return false; if (rootService != null) { try { - if (rootService.writeInput(vmId, channel, data)) { + var screen = screenId.get(); + if (rootService.writeInput(vmId, screen == null ? "" : screen, channel, data)) { lastWriteDirect = true; return true; } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/EvdevEncoder.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/EvdevEncoder.java index a78b2d2b..f43cb89d 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/EvdevEncoder.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/EvdevEncoder.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.ui.vm.display.nativedisplay.input; import android.view.MotionEvent; @@ -38,10 +41,32 @@ public final class EvdevEncoder { private static final short ABS_MT_POSITION_Y = 0x36; private static final short ABS_MT_TRACKING_ID = 0x39; + /** + * Fixed ABS_X/ABS_Y (and ABS_MT_POSITION_X/Y) maximum the guest absolute-mouse / multi-touch + * devices advertise when the daemon omits an explicit resolution ({@code --input + * absolute-mouse}/{@code multi-touch} with no width/height). View coordinates are scaled to this + * range against the on-screen view size, so the guest maps them 1:1 to its screen at any + * resolution -- resolution-independent and auto-resize-proof. MUST equal crosvm's + * {@code NORMALIZED_ABS_MAX} (config.rs) and {@code VNC_ABS_MAX} (gpu_display_vnc.rs). + */ + public static final int NORMALIZED_ABS_MAX = 0x7FFF; + private static final short BTN_TOUCH = 0x14a; + // Relative mouse (InputMode.MOUSE). + private static final short EV_REL = 0x02; + private static final short REL_X = 0x00; + private static final short REL_Y = 0x01; + private static final short REL_WHEEL = 0x08; + private static final short REL_HWHEEL = 0x06; + public static final short BTN_LEFT = 0x110; + public static final short BTN_RIGHT = 0x111; + public static final short BTN_MIDDLE = 0x112; + /** Live Android pointer id -> evdev MT slot. Touched only on the worker thread. */ private final Map pointerSlots = new HashMap<>(); + /** Last guest-space position sent per live pointer id, for the keepalive re-send. */ + private final Map pointerPos = new HashMap<>(); public EvdevEncoder() { } @@ -83,6 +108,93 @@ public static byte[] encodeKey(short scanCode, boolean down) { return encode(events); } + /** Relative pointer motion for {@code InputMode.MOUSE}; null if there is no movement. */ + @Nullable + public static byte[] encodeMouseMove(int dx, int dy) { + if (dx == 0 && dy == 0) return null; + var events = new ArrayList(3); + if (dx != 0) events.add(new Event(EV_REL, REL_X, dx)); + if (dy != 0) events.add(new Event(EV_REL, REL_Y, dy)); + events.add(new Event(EV_SYN, SYN_REPORT, 0)); + return encode(events); + } + + /** Mouse button ({@link #BTN_LEFT}/{@link #BTN_RIGHT}/{@link #BTN_MIDDLE}) press or release. */ + @NonNull + public static byte[] encodeMouseButton(short button, boolean down) { + var events = new ArrayList(2); + events.add(new Event(EV_KEY, button, down ? 1 : 0)); + events.add(new Event(EV_SYN, SYN_REPORT, 0)); + return encode(events); + } + + /** Scroll wheel: vertical (REL_WHEEL, +up/-down) and horizontal (REL_HWHEEL) notches; null if 0. */ + @Nullable + public static byte[] encodeMouseWheel(int vNotches, int hNotches) { + if (vNotches == 0 && hNotches == 0) return null; + var events = new ArrayList(3); + if (vNotches != 0) events.add(new Event(EV_REL, REL_WHEEL, vNotches)); + if (hNotches != 0) events.add(new Event(EV_REL, REL_HWHEEL, hNotches)); + events.add(new Event(EV_SYN, SYN_REPORT, 0)); + return encode(events); + } + + /** + * Absolute-mouse "tablet" ({@code InputMode.TABLET}): the primary pointer mapped onto the guest + * absolute mouse's ABS_X/ABS_Y, with BTN_LEFT for the touch/click. Because the guest device is an + * absolute pointer (qemu usb-tablet), not a BTN_TOUCH touchscreen, the same device also carries + * hover ({@link #encodeAbsMove}), right/middle click ({@link #encodeMouseButton}) and scroll + * ({@link #encodeMouseWheel}). ABS range must equal the guest resolution + * ({@code --input absolute-mouse[width=guestW,height=guestH]}). + * + * @param scaleX guestWidth / viewWidth + * @param scaleY guestHeight / viewHeight + */ + @Nullable + public byte[] encodeTablet(@NonNull MotionEvent event, float scaleX, float scaleY) { + int x = (int) (event.getX() * scaleX); + int y = (int) (event.getY() * scaleY); + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: { + var events = new ArrayList(4); + events.add(new Event(EV_ABS, ABS_X, x)); + events.add(new Event(EV_ABS, ABS_Y, y)); + events.add(new Event(EV_KEY, BTN_LEFT, 1)); + events.add(new Event(EV_SYN, SYN_REPORT, 0)); + return encode(events); + } + case MotionEvent.ACTION_MOVE: { + var events = new ArrayList(3); + events.add(new Event(EV_ABS, ABS_X, x)); + events.add(new Event(EV_ABS, ABS_Y, y)); + events.add(new Event(EV_SYN, SYN_REPORT, 0)); + return encode(events); + } + case MotionEvent.ACTION_UP: + case MotionEvent.ACTION_CANCEL: { + var events = new ArrayList(2); + events.add(new Event(EV_KEY, BTN_LEFT, 0)); + events.add(new Event(EV_SYN, SYN_REPORT, 0)); + return encode(events); + } + default: + return null; + } + } + + /** + * Absolute pointer position with no button held (hover) for the absolute-mouse/tablet device. + * Coordinates are already in guest space. + */ + @NonNull + public static byte[] encodeAbsMove(int x, int y) { + var events = new ArrayList(3); + events.add(new Event(EV_ABS, ABS_X, x)); + events.add(new Event(EV_ABS, ABS_Y, y)); + events.add(new Event(EV_SYN, SYN_REPORT, 0)); + return encode(events); + } + /** * Encodes a touch {@link MotionEvent} into multi-touch evdev records, or null if there is * nothing to send. The multi-touch device's ABS range must equal the guest resolution passed @@ -103,6 +215,7 @@ public byte[] encodeTouch(@NonNull MotionEvent event, float scaleX, float scaleY if (slot == null) continue; // no DOWN seen for this pointer; ignore int x = (int) (event.getX(i) * scaleX); int y = (int) (event.getY(i) * scaleY); + pointerPos.put(id, new int[]{x, y}); events.add(new Event(EV_ABS, ABS_MT_SLOT, slot)); events.add(new Event(EV_ABS, ABS_MT_POSITION_X, x)); events.add(new Event(EV_ABS, ABS_MT_POSITION_Y, y)); @@ -122,6 +235,7 @@ public byte[] encodeTouch(@NonNull MotionEvent event, float scaleX, float scaleY int slot = allocSlot(id); int x = (int) (event.getX(idx) * scaleX); int y = (int) (event.getY(idx) * scaleY); + pointerPos.put(id, new int[]{x, y}); var events = new ArrayList(8); // Only the first contact toggles BTN_TOUCH; further fingers must not re-assert it. if (pointerSlots.size() == 1) @@ -142,6 +256,7 @@ public byte[] encodeTouch(@NonNull MotionEvent event, float scaleX, float scaleY int id = event.getPointerId(event.getActionIndex()); Integer slot = pointerSlots.remove(id); if (slot == null) return null; + pointerPos.remove(id); var events = new ArrayList(4); events.add(new Event(EV_ABS, ABS_MT_SLOT, slot)); events.add(new Event(EV_ABS, ABS_MT_TRACKING_ID, -1)); @@ -160,6 +275,7 @@ public byte[] encodeTouch(@NonNull MotionEvent event, float scaleX, float scaleY events.add(new Event(EV_ABS, ABS_MT_TRACKING_ID, -1)); } pointerSlots.clear(); + pointerPos.clear(); events.add(new Event(EV_KEY, BTN_TOUCH, 0)); events.add(new Event(EV_SYN, SYN_REPORT, 0)); return encode(events); @@ -169,6 +285,39 @@ public byte[] encodeTouch(@NonNull MotionEvent event, float scaleX, float scaleY } } + /** Whether any touch contact is currently down (keepalive needed while true). */ + public boolean hasTouchContacts() { + return !pointerSlots.isEmpty(); + } + + /** + * Re-sends every live contact at its last position, or null if there are no contacts. Real + * touch hardware reports at scan rate for as long as a finger is on the glass, even when + * nothing changes, and guests rely on that: the Windows touch stack ages out a contact whose + * reports stop (a stationary finger otherwise reads as a lift, then its next micro-movement + * as a fresh touchdown). This frame is what a quiet scan cycle would have produced. + */ + @Nullable + public byte[] encodeTouchKeepalive() { + if (pointerSlots.isEmpty()) return null; + var events = new ArrayList(pointerSlots.size() * 3 + 3); + for (var entry : pointerSlots.entrySet()) { + int[] pos = pointerPos.get(entry.getKey()); + if (pos == null) continue; + int slot = entry.getValue(); + events.add(new Event(EV_ABS, ABS_MT_SLOT, slot)); + events.add(new Event(EV_ABS, ABS_MT_POSITION_X, pos[0])); + events.add(new Event(EV_ABS, ABS_MT_POSITION_Y, pos[1])); + if (slot == 0) { + events.add(new Event(EV_ABS, ABS_X, pos[0])); + events.add(new Event(EV_ABS, ABS_Y, pos[1])); + } + } + if (events.isEmpty()) return null; + events.add(new Event(EV_SYN, SYN_REPORT, 0)); + return encode(events); + } + /** Maps a pointer id to a stable slot, allocating the lowest free slot index if new. */ private int allocSlot(int pointerId) { Integer existing = pointerSlots.get(pointerId); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/InputForwarder.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/InputForwarder.java index ce192c4a..9b4c8f5d 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/InputForwarder.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/InputForwarder.java @@ -1,17 +1,24 @@ +// 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.ui.vm.display.nativedisplay.input; import static cn.classfun.droidvm.lib.store.vm.NativeDisplay.KEYBOARD; +import static cn.classfun.droidvm.lib.store.vm.NativeDisplay.MOUSE; import static cn.classfun.droidvm.lib.store.vm.NativeDisplay.MULTITOUCH; +import static cn.classfun.droidvm.lib.store.vm.NativeDisplay.TABLET; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; -import android.os.SystemClock; +import cn.classfun.droidvm.ui.vm.display.base.InputMode; + import android.util.Log; import android.view.MotionEvent; import androidx.annotation.NonNull; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; /** @@ -21,10 +28,14 @@ * serialized on one background thread; MotionEvents are copied first because the framework recycles * the originals. * - * ACTION_MOVE is coalesced: the synchronous per-event IPC round-trip is far slower than the touch - * sample rate, so unbounded moves would pile up in the worker queue and the on-screen pointer would - * fall seconds behind the finger. Only the newest pending move is kept; discrete DOWN/UP/POINTER_* - * events are never dropped and stay ordered. MVP scope: touch + keyboard. + * Pointer motion is coalesced: the synchronous per-event IPC round-trip is far slower than the + * touch sample rate, so unbounded moves would pile up in the worker queue and the on-screen pointer + * would fall seconds behind the finger. Each motion stream keeps one pending slot with at most one + * drain task in the queue: touch ACTION_MOVE keeps the newest frame, relative mouse deltas sum up + * (a sum of deltas is one correct larger delta), tablet/hover positions keep the newest position. + * Discrete events (buttons, wheel, DOWN/UP) are never dropped. FIFO keeps a discrete event behind + * all motion submitted before it; motion submitted after it may fold into an older pending slot + * and arrive up to one frame early, the same tradeoff the touch path already makes. */ public final class InputForwarder { private static final String TAG = "InputForwarder"; @@ -37,15 +48,43 @@ public interface InputSink { private final InputSink sink; // Stateful touch encoder (pointer-id -> slot, contact count); single-threaded on the worker. private final EvdevEncoder encoder = new EvdevEncoder(); - private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> { + + // Current pointer mode; set from the UI thread, read on the worker. Volatile suffices: a change + // just takes effect on the next event. TOUCH -> multi-touch, MOUSE -> relative, TABLET -> single. + private volatile InputMode inputMode = InputMode.TOUCH; + // Mouse-mode gesture state (InputMode.MOUSE); touched only on the worker thread. + private float mouseLastX, mouseLastY, mouseDownX, mouseDownY; + private long mouseDownTime; + private boolean mouseDragging; + private static final float MOUSE_TAP_SLOP = 16f; // guest px of travel before a press is a drag + private static final long MOUSE_TAP_MS = 250; // max press duration still treated as a tap + // Touch keepalive period. Real touch hardware reports at scan rate while a finger is on the + // glass; the Windows touch stack ages out a contact whose reports stop, so a stationary finger + // (Android sends MOVE only on change) reads as lift + fresh touchdown on its next twitch. Well + // under any plausible aging threshold (real digitizers scan at 60Hz+), still only ~20 frames/s. + private static final long TOUCH_KEEPALIVE_MS = 50; + private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(r -> { var t = new Thread(r, "InputForwarder"); t.setDaemon(true); return t; }); + // Whether a keepalive tick is scheduled; touched only on the worker thread. + private boolean keepaliveScheduled; // Newest pending ACTION_MOVE, coalesced so a fast finger can't outrun the synchronous IPC. private final AtomicReference pendingMove = new AtomicReference<>(); + // Pending-motion slots for the MOUSE/TABLET gesture paths, guarded by motionLock. Each slot + // has at most one drain task in the worker queue (the *Pending flag); producers on the UI + // thread fold into the slot while a drain is queued instead of submitting more tasks. + private final Object motionLock = new Object(); + private int pendMouseDx, pendMouseDy; // relative mouse motion, summed (guest px) + private boolean mouseMovePending; + private int pendAbsX, pendAbsY; // tablet absolute position, newest wins (guest px) + private boolean absMovePending; + private int pendHoverX, pendHoverY; // hover position, newest wins (guest px) + private boolean hoverPending; + private static final class TouchFrame { final MotionEvent event; final float scaleX; @@ -62,6 +101,142 @@ public InputForwarder(@NonNull InputSink sink) { this.sink = sink; } + /** Switches pointer mode at runtime; takes effect on the next touch event. */ + public void setInputMode(@NonNull InputMode mode) { + this.inputMode = mode; + } + + /** The guest pointer device the current mode routes host mouse/stylus events to. */ + private int pointerChannel() { + return inputMode == InputMode.TABLET ? TABLET : MOUSE; + } + + /** + * A pointer button ({@link EvdevEncoder#BTN_RIGHT}/{@link EvdevEncoder#BTN_MIDDLE}) press/release + * from a host mouse or stylus. Left-click still rides the touch/tap path. Routed to the tablet + * (absolute mouse) in TABLET mode, otherwise the relative mouse. + */ + public void sendPointerButton(short button, boolean down) { + submit("pointerButton", () -> sink.write(pointerChannel(), + EvdevEncoder.encodeMouseButton(button, down))); + } + + /** + * Relative cursor motion (guest px) on the relative-mouse device; the guest renders the cursor. + * Deltas are summed into the pending slot, so any number of calls between two worker turns + * still costs one IPC round-trip and the pointer can't fall behind the finger. + */ + public void sendMouseMove(int dxGuest, int dyGuest) { + if (dxGuest == 0 && dyGuest == 0) return; + boolean schedule; + synchronized (motionLock) { + pendMouseDx += dxGuest; + pendMouseDy += dyGuest; + schedule = !mouseMovePending; + mouseMovePending = true; + } + if (schedule) submit("mouseMove", this::drainMouseMove); + } + + /** Absolute pointer position (guest px, no button change) on the tablet device; newest wins. */ + public void sendAbsMove(int xGuest, int yGuest) { + boolean schedule; + synchronized (motionLock) { + pendAbsX = xGuest; + pendAbsY = yGuest; + schedule = !absMovePending; + absMovePending = true; + } + if (schedule) submit("absMove", this::drainAbsMove); + } + + /** Left button on the tablet device, positioned first so the press lands at (x, y) guest px. */ + public void sendAbsLeftButton(boolean down, int xGuest, int yGuest) { + submit("absLeft", () -> { + sink.write(TABLET, EvdevEncoder.encodeAbsMove(xGuest, yGuest)); + sink.write(TABLET, EvdevEncoder.encodeMouseButton(EvdevEncoder.BTN_LEFT, down)); + }); + } + + /** Host scroll wheel (vertical, horizontal notches) routed to the active pointer device. */ + public void sendScroll(int vNotches, int hNotches) { + submit("scroll", () -> { + byte[] data = EvdevEncoder.encodeMouseWheel(vNotches, hNotches); + if (data != null) sink.write(pointerChannel(), data); + }); + } + + /** + * Host pointer hover (no button held). In TABLET mode it becomes an absolute position on the + * guest tablet (native hover); otherwise a relative delta so the guest mouse cursor follows. + * Coordinates are view pixels; scale maps them to guest space. + */ + public void sendHover(float viewX, float viewY, float scaleX, float scaleY) { + int gx = (int) (viewX * scaleX); + int gy = (int) (viewY * scaleY); + boolean schedule; + synchronized (motionLock) { + pendHoverX = gx; + pendHoverY = gy; + schedule = !hoverPending; + hoverPending = true; + } + if (schedule) submit("hover", this::drainHover); + } + + // The drains below run on the worker thread only. Between a drain's read-and-clear and its + // sink.write, a producer may refill the slot and queue the next drain task; nothing is lost + // and the queue still holds at most one task per stream. + + private void drainMouseMove() { + int dx, dy; + synchronized (motionLock) { + dx = pendMouseDx; + dy = pendMouseDy; + pendMouseDx = 0; + pendMouseDy = 0; + mouseMovePending = false; + } + // dx/dy may legitimately sum to zero (back-and-forth motion); encode returns null then. + byte[] data = EvdevEncoder.encodeMouseMove(dx, dy); + if (data != null) sink.write(MOUSE, data); + } + + private void drainAbsMove() { + int x, y; + boolean had; + synchronized (motionLock) { + had = absMovePending; + x = pendAbsX; + y = pendAbsY; + absMovePending = false; + } + if (!had) return; + sink.write(TABLET, EvdevEncoder.encodeAbsMove(x, y)); + } + + private void drainHover() { + int gx, gy; + boolean had; + synchronized (motionLock) { + had = hoverPending; + gx = pendHoverX; + gy = pendHoverY; + hoverPending = false; + } + if (!had) return; + if (inputMode == InputMode.TABLET) { + sink.write(TABLET, EvdevEncoder.encodeAbsMove(gx, gy)); + } else { + int dx = Math.round(gx - mouseLastX); + int dy = Math.round(gy - mouseLastY); + mouseLastX = gx; + mouseLastY = gy; + byte[] data = EvdevEncoder.encodeMouseMove(dx, dy); + if (data != null) sink.write(MOUSE, data); + } + } + private void submit(@NonNull String name, @NonNull Runnable block) { try { worker.execute(() -> { @@ -104,29 +279,100 @@ private void drainMove() { } private void sendTouchNow(@NonNull MotionEvent event, float scaleX, float scaleY) { - // eventTime is on the SystemClock.uptimeMillis() timebase, so the diff below is the full - // finger-to-sink latency (kernel input -> framework dispatch -> our worker -> sink). Read it - // before recycle() since the framework reuses the MotionEvent afterwards. - long eventTimeMs = event.getEventTime(); - byte[] data; try { - data = encoder.encodeTouch(event, scaleX, scaleY); + switch (inputMode) { + case MOUSE: + sendMouseNow(event, scaleX, scaleY); + break; + case TABLET: { + byte[] data = encoder.encodeTablet(event, scaleX, scaleY); + if (data != null) sink.write(TABLET, data); + break; + } + case TOUCH: + default: { + byte[] data = encoder.encodeTouch(event, scaleX, scaleY); + if (data != null) sink.write(MULTITOUCH, data); + scheduleTouchKeepalive(); + break; + } + } } catch (Exception e) { - Log.e(TAG, "encode touch failed", e); - return; + Log.e(TAG, "encode/send pointer failed", e); } finally { event.recycle(); } - if (data == null) return; // nothing to send for this event - long sinkStartNs = System.nanoTime(); - boolean ok = sink.write(MULTITOUCH, data); - double sinkMs = (System.nanoTime() - sinkStartNs) / 1_000_000.0; - long e2eMs = SystemClock.uptimeMillis() - eventTimeMs; - // direct=false means the write fell back to the vm_input JSON-RPC IPC path (~40ms) instead - // of the direct unix socket (<1ms) - i.e. the UI couldn't reach the daemon's UI input socket. - boolean direct = sink instanceof DirectInputSink && ((DirectInputSink) sink).wasLastWriteDirect(); - Log.d(TAG, fmt("touch latency: sink=%.2fms e2e=%dms direct=%b delivered=%b", - sinkMs, e2eMs, direct, ok)); + } + + // Worker thread only. One tick in flight at a time; the chain ends itself when the last + // contact lifts (encodeTouchKeepalive returns null) or the mode leaves TOUCH, and any touch + // event while contacts are down restarts it. + private void scheduleTouchKeepalive() { + if (keepaliveScheduled || !encoder.hasTouchContacts()) return; + keepaliveScheduled = true; + try { + worker.schedule(this::touchKeepaliveTick, TOUCH_KEEPALIVE_MS, TimeUnit.MILLISECONDS); + } catch (Exception e) { + keepaliveScheduled = false; + } + } + + private void touchKeepaliveTick() { + keepaliveScheduled = false; + try { + if (inputMode != InputMode.TOUCH) return; + byte[] data = encoder.encodeTouchKeepalive(); + if (data == null) return; + sink.write(MULTITOUCH, data); + } catch (Exception e) { + Log.e(TAG, "touch keepalive failed", e); + } + scheduleTouchKeepalive(); + } + + // Relative-mouse translation (InputMode.MOUSE): a drag becomes REL_X/REL_Y motion, a quick tap + // without travel becomes a left click. Runs on the worker thread, so the mouse state needs no + // locking. Move coalescing upstream is fine here: the delta is measured from the last position + // we actually sent, so dropped intermediate samples just fold into one larger (correct) delta. + private void sendMouseNow(@NonNull MotionEvent event, float scaleX, float scaleY) { + float gx = event.getX() * scaleX; + float gy = event.getY() * scaleY; + switch (event.getActionMasked()) { + case MotionEvent.ACTION_DOWN: + mouseLastX = gx; + mouseLastY = gy; + mouseDownX = gx; + mouseDownY = gy; + mouseDownTime = event.getEventTime(); + mouseDragging = false; + break; + case MotionEvent.ACTION_MOVE: { + int dx = Math.round(gx - mouseLastX); + int dy = Math.round(gy - mouseLastY); + if (dx == 0 && dy == 0) break; + mouseLastX = gx; + mouseLastY = gy; + if (!mouseDragging + && (Math.abs(gx - mouseDownX) > MOUSE_TAP_SLOP + || Math.abs(gy - mouseDownY) > MOUSE_TAP_SLOP)) { + mouseDragging = true; + } + byte[] data = EvdevEncoder.encodeMouseMove(dx, dy); + if (data != null) sink.write(MOUSE, data); + break; + } + case MotionEvent.ACTION_UP: { + boolean tap = !mouseDragging + && (event.getEventTime() - mouseDownTime) <= MOUSE_TAP_MS; + if (tap) { + sink.write(MOUSE, EvdevEncoder.encodeMouseButton(EvdevEncoder.BTN_LEFT, true)); + sink.write(MOUSE, EvdevEncoder.encodeMouseButton(EvdevEncoder.BTN_LEFT, false)); + } + break; + } + default: + break; + } } /** @@ -154,6 +400,25 @@ public boolean sendKeyEvent(int keyCode, boolean pressed) { return true; } + /** + * Sends a printable character as a US-layout evdev key tap, wrapping it in LEFTSHIFT down/up + * when the character requires Shift (uppercase letters, {@code !@#...}). This is the path for + * soft keyboards that commit text rather than emitting key events, so uppercase and symbols + * reach the guest correctly instead of being lost or arriving lowercase. + * + * @return true if the character is mapped to a US-layout key; false if the caller should fall + * back to the framework key character map. + */ + public boolean sendChar(char c) { + KeyCodeMapper.CharKey key = KeyCodeMapper.charToKey(c); + if (key == null) return false; + if (key.shift) sendRawKeyEvent(KeyCodeMapper.KEY_LEFTSHIFT, true); + sendRawKeyEvent(key.scanCode, true); + sendRawKeyEvent(key.scanCode, false); + if (key.shift) sendRawKeyEvent(KeyCodeMapper.KEY_LEFTSHIFT, false); + return true; + } + /** Sends a raw Linux evdev KEY_* scan code. */ public void sendRawKeyEvent(int scanCode, boolean pressed) { submit("sendRawKeyEvent", () -> { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/KeyCodeMapper.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/KeyCodeMapper.java index 644eaf13..f3a9114c 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/KeyCodeMapper.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/KeyCodeMapper.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.ui.vm.display.nativedisplay.input; import android.view.KeyEvent; @@ -149,4 +152,70 @@ public static int shiftSynthBase(int keyCode) { return -1; } } + + /** + * A printable character resolved to a US-QWERTY evdev key plus whether Shift must be held. + * The guest keymap is assumed US; the caller wraps {@link #scanCode} with LEFTSHIFT when + * {@link #shift} is set (see InputForwarder.sendChar), which is the "insert shift up/down" + * behaviour needed for uppercase letters and shifted symbols the soft keyboard commits as text. + */ + public static final class CharKey { + public final short scanCode; + public final boolean shift; + + CharKey(int scanCode, boolean shift) { + this.scanCode = (short) scanCode; + this.shift = shift; + } + } + + private static final Map CHAR_MAP = new HashMap<>(); + + private static void ch(char c, int scanCode, boolean shift) { + CHAR_MAP.put(c, new CharKey(scanCode, shift)); + } + + static { + // Letters: lowercase unshifted, uppercase shifted. evdev scan codes follow the physical + // QWERTY layout, NOT the alphabet, so map each letter to its own KEY_* (KEY_A + (c-'a') + // would send e.g. 'q' as KEY_C). + int[] letterKeys = { + KEY_A, KEY_B, KEY_C, KEY_D, KEY_E, KEY_F, KEY_G, KEY_H, KEY_I, KEY_J, KEY_K, KEY_L, + KEY_M, KEY_N, KEY_O, KEY_P, KEY_Q, KEY_R, KEY_S, KEY_T, KEY_U, KEY_V, KEY_W, KEY_X, + KEY_Y, KEY_Z, + }; + for (int i = 0; i < 26; i++) { + ch((char) ('a' + i), letterKeys[i], false); + ch((char) ('A' + i), letterKeys[i], true); + } + // Digit row, unshifted. + int[] digitKeys = {KEY_0, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9}; + for (int d = 0; d <= 9; d++) ch((char) ('0' + d), digitKeys[d], false); + // Digit row, shifted symbols (US layout). + ch(')', KEY_0, true); ch('!', KEY_1, true); ch('@', KEY_2, true); ch('#', KEY_3, true); + ch('$', KEY_4, true); ch('%', KEY_5, true); ch('^', KEY_6, true); ch('&', KEY_7, true); + ch('*', KEY_8, true); ch('(', KEY_9, true); + // Punctuation pairs (unshifted / shifted). + ch('-', KEY_MINUS, false); ch('_', KEY_MINUS, true); + ch('=', KEY_EQUAL, false); ch('+', KEY_EQUAL, true); + ch('[', KEY_LEFTBRACE, false); ch('{', KEY_LEFTBRACE, true); + ch(']', KEY_RIGHTBRACE, false); ch('}', KEY_RIGHTBRACE, true); + ch('\\', KEY_BACKSLASH, false); ch('|', KEY_BACKSLASH, true); + ch(';', KEY_SEMICOLON, false); ch(':', KEY_SEMICOLON, true); + ch('\'', KEY_APOSTROPHE, false); ch('"', KEY_APOSTROPHE, true); + ch('`', KEY_GRAVE, false); ch('~', KEY_GRAVE, true); + ch(',', KEY_COMMA, false); ch('<', KEY_COMMA, true); + ch('.', KEY_DOT, false); ch('>', KEY_DOT, true); + ch('/', KEY_SLASH, false); ch('?', KEY_SLASH, true); + // Whitespace. + ch(' ', KEY_SPACE, false); ch('\t', KEY_TAB, false); ch('\n', KEY_ENTER, false); + } + + /** + * Resolves a printable character to a US-QWERTY evdev key (+ Shift flag), or null if the + * character has no direct US-layout key (caller should fall back to the framework keymap). + */ + public static CharKey charToKey(char c) { + return CHAR_MAP.get(c); + } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/NativeExtraKeysPanel.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/NativeExtraKeysPanel.java index 3cbe10f8..aeed31bf 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/NativeExtraKeysPanel.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/NativeExtraKeysPanel.java @@ -1,9 +1,8 @@ +// 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.ui.vm.display.nativedisplay.input; -import static android.view.KeyEvent.KEYCODE_CAPS_LOCK; -import static android.view.KeyEvent.KEYCODE_MINUS; -import static android.view.KeyEvent.KEYCODE_SLASH; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -12,8 +11,8 @@ /** * Adapts the shared {@link DisplayExtraKeysPanel} to the native backend, emitting evdev key events - * through {@link InputForwarder}. The sticky-modifier handling lives in {@link BaseExtraKeysAdapter}; - * only the emit/ready hooks and the repeat/caps key mapping are backend-specific. + * through {@link InputForwarder}. The sticky-modifier and key down/up handling live in + * {@link BaseExtraKeysAdapter}; only the emit/ready hooks are backend-specific. */ public final class NativeExtraKeysPanel extends BaseExtraKeysAdapter { @Nullable @@ -36,22 +35,4 @@ protected void emitKey(int androidKeyCode, boolean down) { protected boolean isReady() { return forwarder != null; } - - @Override - public void onKeyRepeat(int androidKeyCode) { - tapKey(androidKeyCode); - } - - @Override - public void onCharRepeat(char ch) { - if (ch == '/') tapKey(KEYCODE_SLASH); - else if (ch == '-') tapKey(KEYCODE_MINUS); - } - - @Override - public void onCapsToggle(boolean active) { - if (!isReady()) return; - emitKey(KEYCODE_CAPS_LOCK, true); - emitKey(KEYCODE_CAPS_LOCK, false); - } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/NativeKeyboardEditText.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/NativeKeyboardEditText.java index d7ef0de4..68abe6f8 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/NativeKeyboardEditText.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/NativeKeyboardEditText.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.ui.vm.display.nativedisplay.input; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/TouchScaleCalculator.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/TouchScaleCalculator.java index 85a91bc4..d7e072ef 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/TouchScaleCalculator.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/nativedisplay/input/TouchScaleCalculator.java @@ -1,10 +1,15 @@ +// 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.ui.vm.display.nativedisplay.input; /** - * Computes per-axis touch scale from view size to guest resolution. The touch listener is attached - * to the SurfaceView, which is itself sized to the guest aspect ratio (see - * {@code VMNativeDisplayActivity#updateAspectRatio}), so its bounds carry no letterbox/pillarbox - * bars: there is no offset and scale is simply guest/view per axis. + * Computes per-axis touch scale from view size to the guest device's fixed normalized ABS range + * ({@link EvdevEncoder#NORMALIZED_ABS_MAX}) -- independent of the guest resolution, so it survives + * guest auto-resize. The touch listener is attached to the SurfaceView, which is itself sized to + * the guest aspect ratio (see {@code VMNativeDisplayActivity#updateAspectRatio}), so its bounds + * carry no letterbox/pillarbox bars: there is no offset and scale is simply NORMALIZED_ABS_MAX/view + * per axis. */ public final class TouchScaleCalculator { private TouchScaleCalculator() { @@ -20,12 +25,15 @@ public static final class TouchTransform { } } - public static TouchTransform compute(int guestWidth, int guestHeight, - int viewWidth, int viewHeight) { - if (viewWidth <= 0 || viewHeight <= 0 || guestWidth <= 0 || guestHeight <= 0) { + public static TouchTransform compute(int viewWidth, int viewHeight) { + if (viewWidth <= 0 || viewHeight <= 0) { return new TouchTransform(1f, 1f); } - return new TouchTransform((float) guestWidth / viewWidth, - (float) guestHeight / viewHeight); + // Normalize view coords to the fixed ABS range (EvdevEncoder.NORMALIZED_ABS_MAX): the guest + // then maps them 1:1 to its screen at ANY resolution, so no guest resolution is needed here. + // Auto-resize just changes the view size, which this already tracks per call. + return new TouchTransform( + (float) EvdevEncoder.NORMALIZED_ABS_MAX / viewWidth, + (float) EvdevEncoder.NORMALIZED_ABS_MAX / viewHeight); } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/BaseVncActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/BaseVncActivity.java index e5447a5d..30a881ff 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/BaseVncActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/BaseVncActivity.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.ui.vm.display.vnc.base; import static android.content.DialogInterface.BUTTON_NEUTRAL; @@ -14,8 +17,6 @@ import android.annotation.SuppressLint; import android.content.ActivityNotFoundException; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.DialogInterface; import android.content.Intent; import android.content.pm.ActivityInfo; @@ -25,17 +26,22 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.os.SystemClock; import android.util.Log; import android.view.KeyEvent; import android.view.MenuItem; +import android.view.TextureView; import android.view.View; import android.view.WindowManager; import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; import android.widget.LinearLayout; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.MainThread; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import com.google.android.material.appbar.MaterialToolbar; @@ -46,17 +52,42 @@ import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.daemon.DaemonConnection; +import cn.classfun.droidvm.lib.perf.GamePerfHint; +import cn.classfun.droidvm.lib.ui.CopyableField; import cn.classfun.droidvm.lib.ui.ImeInsetsExempt; import cn.classfun.droidvm.ui.vm.display.base.DisplayExtraKeysPanel; +import cn.classfun.droidvm.ui.vm.display.vnc.h264.H264ConsolePipeline; +import cn.classfun.droidvm.ui.vm.display.vnc.h264.H264ProbePolicy; +import cn.classfun.droidvm.ui.vm.display.vnc.h264.H264RectProtocol; +import cn.classfun.droidvm.ui.vm.display.vnc.h264.H264SyncFrameCache; import cn.classfun.droidvm.ui.vm.display.vnc.input.VncExtraKeysPanel; public abstract class BaseVncActivity extends AppCompatActivity implements ImeInsetsExempt { protected final String TAG = getClass().getSimpleName(); public static final String EXTRA_VM_NAME = "vm_name"; public static final String EXTRA_VM_ID = "vm_id"; + /** + * Which screen's VNC server to connect to. A VM can run one per screen on different ports, + * so the daemon is asked for that screen's settings rather than for "the VM's VNC". + */ + public static final String EXTRA_SCREEN = "screen"; + /** + * Whether that screen was configured with its own absolute input devices. Used only to say + * why an input mode is doing nothing; where the events go is the daemon's answer, not this + * one. Modes that ride RFB (the tablet pointer, the keyboard) are unaffected either way. + */ + public static final String EXTRA_INPUT_ENABLED = "input_enabled"; protected static final int DEFAULT_PORT = 5900; private static final int MAX_RECONNECT_ATTEMPTS = 5; private static final long RECONNECT_DELAY_MS = 2000; + /** + * How often the H.264 policy's two silence clocks are read. + * + *

    Both of them are measured in seconds, so a second of granularity costs nothing and this is + * the only timer the console needs for the whole H.264 path: everything else about it is driven + * by rects arriving.

    + */ + private static final long H264_TICK_MS = 1000; protected final Handler mainHandler = new Handler(Looper.getMainLooper()); protected final ExecutorService executor = newSingleThreadExecutor(this::msgLoopThread); protected VncClient vncClient; @@ -70,6 +101,10 @@ public abstract class BaseVncActivity extends AppCompatActivity implements ImeIn protected VncExtraKeysPanel vncExtraKeys; protected String vmName = ""; protected String vmId = ""; + /** Screen whose VNC server this view shows; empty means "the VM's first bound one". */ + protected String screenId = ""; + /** Whether that screen has absolute input devices at all; see {@link #EXTRA_INPUT_ENABLED}. */ + protected boolean screenInputEnabled = true; protected String vncHost = "127.0.0.1"; // Phone LAN address the daemon resolved for an IPv4-wildcard bind (offload // proxy IPs already excluded); empty when not applicable. Preferred over @@ -79,6 +114,34 @@ public abstract class BaseVncActivity extends AppCompatActivity implements ImeIn protected String vncPassword = null; protected volatile boolean running = false; protected volatile boolean needsRefresh = false; + /** + * The decoder view, when this console has one; null leaves it RFB-only. + * + *

    Null does not stop the stream arriving: the encodings are asked for by the RFB client + * itself, before any of this exists. What it means is that the rects have nowhere to go, which + * is only ever true of a presentation console that has not been given a display yet -- and that + * console shows nothing on the phone either, so there is no picture to freeze.

    + */ + protected TextureView h264View; + /** Read on the message-loop thread, written on the main one; see {@link #setH264View}. */ + @Nullable + private volatile H264ConsolePipeline h264; + /** What this console is doing about H.264 and why. See {@link H264ProbePolicy}. */ + private final H264ProbePolicy h264Probe = new H264ProbePolicy(); + /** + * The rect a decoder can start on, held for the connection rather than for the pipeline. + * + *

    Here rather than inside {@link H264ConsolePipeline} because it has to survive one: this + * console's pipeline is built when there is a view to draw into, which on the presentation + * console is after a display has been chosen and again after every window rebuild, while the + * stream -- and the one reset-flagged rect that carries its parameter sets -- rides a + * connection that started earlier and does not stop for any of it.

    + */ + private final H264SyncFrameCache syncFrames = new H264SyncFrameCache(); + private final Runnable h264Tick = this::tickH264; + /** What {@link #setStatus} last put in the status line, and the note appended to it. */ + private String statusText = ""; + private String statusNote = ""; private int reconnectAttempt = 0; protected int fbWidth, fbHeight; protected Bitmap displayBitmap; @@ -142,18 +205,44 @@ protected void onCreate(Bundle savedInstanceState) { if (vmName == null) vmName = ""; vmId = intent.getStringExtra(EXTRA_VM_ID); if (vmId == null) vmId = ""; + screenId = intent.getStringExtra(EXTRA_SCREEN); + if (screenId == null) screenId = ""; + screenInputEnabled = intent.getBooleanExtra(EXTRA_INPUT_ENABLED, true); bindViews(); setupToolbar(); - onSetupActivity(); + // Before onSetupActivity() so subclasses can wire views (e.g. the physical keyboard) + // to the adapter during their setup. vncExtraKeys = new VncExtraKeysPanel(extraKeysPanel); + onSetupActivity(); fetchVncInfoAndConnect(); } + @Override + protected void onResume() { + super.onResume(); + // A VM display is on screen and rendering: tell the platform this is sustained heavy + // gameplay so its power policy raises clocks (see GamePerfHint). + GamePerfHint.enterGameplay(this); + // Nothing to restart here any more. Going to the background takes the decoder's surface + // with the window, and coming back gives it a new one -- which the pipeline hears about + // from the view itself. The stream never stopped: it rides the RFB connection, which stays + // up the whole time, so the next rect after the surface returns is the one that draws. + } + + @Override + protected void onPause() { + super.onPause(); + GamePerfHint.exitGameplay(this); + } + @Override protected void onDestroy() { super.onDestroy(); onDestroyExtra(); - extraKeysPanel.stopKeyRepeat(); + // First: it holds a codec, and the loop below waits for the message-loop thread that may + // be parked inside a submit to it. + mainHandler.removeCallbacks(h264Tick); + stopH264(); running = false; if (vncClient != null) vncClient.requestStop(); executor.shutdown(); @@ -184,6 +273,10 @@ private void bindViews() { tvConnectingMessage = findViewById(R.id.tv_connecting_message); overlayConnecting = findViewById(R.id.overlay_connecting); ivDisplay = findViewById(R.id.iv_display); + // Null for a layout that has no decoder view of its own, which is both how a console opts + // out of the H.264 path entirely and how the presentation defers the question until it + // knows which display it is putting the picture on. + setH264View(findViewById(R.id.texture_h264)); extraKeysPanel = findViewById(R.id.extra_keys_panel); onBindExtraViews(); } @@ -225,15 +318,26 @@ protected void fetchVncInfoAndConnect() { }); }; DaemonConnection.OnResponse res = resp -> { + // Adopt the screen the daemon resolved. Asking for "the VM's VNC" is answered with a + // particular screen's server, and input has to agree with that answer: the absolute + // devices are per screen, so a console still holding "" would have nowhere to send a + // touch even though it is showing a screen that has one. + var resolved = resp.optString("screen", ""); + if (!resolved.isEmpty()) screenId = resolved; vncHost = resp.optString("host", "127.0.0.1"); vncRemoteHost = resp.optString("remote_host", ""); vncPort = resp.optInt("port", DEFAULT_PORT); + // Nothing here about H.264 any more. There is no second port to be told about, and the + // binding's transport ceiling is not the answer either -- it says what the host is + // permitted to build, and what the console needs is whether it built one. That is + // answered on the connection itself, by the capabilities rect. vncPassword = resp.optString("password", ""); if (vncPassword.isEmpty()) vncPassword = null; mainHandler.post(this::startVnc); }; DaemonConnection.getInstance().buildRequest("vm_vnc_info") .put("vm_id", vmId) + .put("screen", screenId) .onResponse(res) .onUnsuccessful(f) .onError(err) @@ -259,6 +363,10 @@ public void onFramebufferResized(int width, int height) { setStatus(getString(R.string.vnc_display_connected, width, height), VncStatus.CONNECTED); hideConnectingOverlay(); onFramebufferReady(width, height); + // Nothing to restart for the decoder: a guest resize reaches it as an + // encoding-50 rect at the new coded size with the reset flags set, which is a new + // decoder generation and a sync frame to start it on, arriving in that order + // because the server sends the DesktopSize rect first. }); } @@ -266,6 +374,43 @@ public void onFramebufferResized(int width, int height) { public void onFramebufferUpdated(int x, int y, int w, int h) { needsRefresh = true; } + + @Override + public void onH264Rect(@NonNull byte[] rect, int width, int height) { + // On the message-loop thread. Both of these are told the time by the caller rather + // than reading a clock of their own, which is what lets the schedule be tested. + h264Probe.onStreamRect(SystemClock.elapsedRealtime()); + var pipeline = h264; + if (pipeline != null) { + pipeline.submitStreamRect(rect, width, height); + return; + } + // No pipeline yet, which for the presentation console is the ordinary state until a + // display has been chosen -- the connection does not wait for that choice, and the rect + // that starts the stream is sent once, on joining. Dropping it outright is what left + // that console showing nothing: the bare IDRs that follow carry no parameter sets, and + // nothing on the wire asks for another. Kept here instead, and the first pipeline built + // afterwards primes its decoder with it. + syncFrames.rememberIfSync(rect, width, height); + } + + @Override + public void onDvhRect(@NonNull byte[] payload) { + var dvh = H264RectProtocol.parseDvhRect(payload); + // A rect this build cannot read is ignored rather than reported: that is what the + // version byte is for, and dropping the connection over a newer host's vocabulary + // would turn a gap into an outage. + if (dvh == null) return; + var now = SystemClock.elapsedRealtime(); + if (dvh.isHeartbeat()) { + h264Probe.onHeartbeat(now); + return; + } + if (!dvh.isCapabilities()) return; + Log.i(TAG, fmt("H.264 capabilities rect: value %d", dvh.value)); + h264Probe.onCapsRect(dvh.value, now); + mainHandler.post(BaseVncActivity.this::applyH264Mode); + } } private void startVnc() { @@ -294,6 +439,11 @@ private void startVnc() { } running = true; reconnectAttempt = 0; + // The five-second capabilities clock starts at the connection, not at the first + // picture: the server answers the client's first request with the capabilities rect, + // so a server that is going to say anything has said it by then. + h264Probe.onConnected(SystemClock.elapsedRealtime()); + mainHandler.post(this::startH264Ticks); mainHandler.post(() -> { int w = vncClient.getWidth(); int h = vncClient.getHeight(); @@ -306,6 +456,16 @@ private void startVnc() { }); } + /** + * Reads whatever the server has to say, for as long as this console is up. + * + *

    It no longer stops reading while a decoder is painting the screen, and it cannot: the + * H.264 stream arrives on this connection, as rects, so a loop that stopped handling messages + * would stop the picture it was trying to make room for. Suppressing the pixel work is the + * server's job now -- it empties an enrolled client's modified region rather than encoding it + * -- and the framebuffer copy on this side stops on its own, because the rect handlers say the + * framebuffer did not move.

    + */ private void messageLoop() { var client = vncClient; while (running && client != null && client.isConnected()) { @@ -317,6 +477,12 @@ private void messageLoop() { } boolean wasRunning = running; running = false; + h264Probe.onDisconnected(); + // The sync frame belonged to this connection's stream. The next connection joins the stream + // again and is sent its own; keeping this one would prime a decoder with the parameter sets + // of a stream nobody is sending any more. Cleared from this thread because this is the + // thread that writes it -- the loop above is the only other place it is touched. + syncFrames.clear(); Log.i(TAG, "message loop ended"); if (wasRunning) { mainHandler.post(this::scheduleAutoReconnect); @@ -328,6 +494,9 @@ private void scheduleAutoReconnect() { Log.w(TAG, "Executor already shut down, skipping reconnect"); return; } + // The decoder belongs to the RFB session that just ended -- the stream rode it -- so it + // goes with it. The reconnected session enrols itself and starts a new one. + stopH264(); reconnectAttempt++; if (reconnectAttempt > MAX_RECONNECT_ATTEMPTS) { var msg = getString(R.string.vnc_display_reconnect_failed, @@ -381,6 +550,173 @@ private void refreshDisplay() { }); } + /** + * Binds the view the decoder draws into, or unbinds it with null. + * + *

    Here rather than only in {@link #bindViews} because one console's decoder surface is not + * in its own layout: the presentation puts the guest's picture on another display entirely, in + * a window that does not exist until a display has been chosen and can be dismissed and rebuilt + * while the console stays open. Rebinding is a whole new pipeline, since the view a pipeline + * draws into is the one thing about it that cannot change underneath.

    + * + *

    Binding does not ask for anything. Nothing has to be asked for any more: the encodings go + * out with the connection, and a pipeline bound halfway through a stream starts decoding at the + * next rect that reaches it.

    + */ + protected void setH264View(@Nullable TextureView view) { + if (h264View == view) return; + stopH264(); + h264View = view; + h264 = view == null ? null + : new H264ConsolePipeline(view, mainHandler, new H264Listener(), syncFrames); + } + + /** + * Takes the H.264 path down. + * + *

    Deliberately does not stop the policy's tick. One of the callers is + * {@link #setH264View}, which runs while the connection is perfectly alive -- the presentation + * console rebinds its decoder view when a display is chosen -- and a tick cancelled there would + * never be posted again, leaving the console with no clock for the rest of its life. The tick + * stops on its own when the message loop does, because that is the thing it is about.

    + */ + private void stopH264() { + var pipeline = h264; + if (pipeline != null) pipeline.stop(); + } + + /** Starts the once-a-second read of the policy's two silence clocks. Idempotent. */ + @MainThread + private void startH264Ticks() { + mainHandler.removeCallbacks(h264Tick); + mainHandler.postDelayed(h264Tick, H264_TICK_MS); + } + + /** + * One read of the policy's clocks, and whatever it asks for as a result. + * + *

    This is the whole of the timing half of {@code H264_SINGLE_PORT.md} section 1: five + * seconds of no capabilities rect means the server is not one that knows about them, and ten + * seconds of neither frame nor heartbeat while decoding means the stream is dead however alive + * the socket looks. Everything else on the H.264 path is driven by rects arriving.

    + */ + @MainThread + private void tickH264() { + if (isFinishing() || isDestroyed()) return; + var order = h264Probe.tick(SystemClock.elapsedRealtime()); + applyH264Mode(); + if (order == H264ProbePolicy.Order.RECONNECT) { + // The stream is dead and the connection is where enrolment happens, so the only way to + // ask for a new one is a new connection. Nothing else here reconnects on its own: the + // message loop only does so when the socket itself failed, and this failure is a + // socket that is fine and a picture that stopped. + Log.w(TAG, "the H.264 stream went silent; reconnecting"); + reconnect(); + return; + } + if (running) mainHandler.postDelayed(h264Tick, H264_TICK_MS); + } + + /** + * Makes the console show what the policy says it should be showing. + * + *

    Only ever takes the decoder down, never puts it up: a pipeline is started by a rect + * arriving, not by a decision here. What this settles is the two things a decision can settle + * on its own -- whether a pipeline that is up should stay up, and what the status line says.

    + */ + @MainThread + private void applyH264Mode() { + var pipeline = h264; + if (h264Probe.mode() != H264ProbePolicy.Mode.DECODING && pipeline != null) { + if (h264Probe.isPermanent()) pipeline.disable(); + else pipeline.stop(); + } + // Said only for the host that answered "no encoder", which is the one case where this + // console can never do better and the user might otherwise wonder why. Silence is not that + // case: an ordinary VNC server never offered a stream, and telling its user that H.264 is + // unavailable would be noise on every screen that was never going to have one. + if (h264Probe.saidNoEncoder()) + setStatusNote(getString(R.string.vnc_display_h264_unavailable)); + } + + /** Whether the H.264 decoder is what is currently painting this console. */ + protected boolean isH264Live() { + var pipeline = h264; + return pipeline != null && pipeline.isLive(); + } + + /** The console's own view of the H.264 path, on the main thread. */ + private final class H264Listener implements H264ConsolePipeline.Listener { + @Override + public void onStreamLive(int width, int height) { + setStatusNote(getString(R.string.vnc_display_h264_active)); + onH264StreamChanged(true, width, height); + } + + @Override + public void onStreamGone(boolean wasLive, @Nullable Exception cause) { + onH264StreamChanged(false, 0, 0); + if (cause == null) { + // Nothing went wrong: the console is closing, or its window went away, and the + // rects are still arriving on a connection that is still up. + setStatusNote(null); + return; + } + if (cause instanceof H264ConsolePipeline.NoDecoderException) { + // The one failure the console has to act on rather than merely report. A client + // that asked for encoding 50 is served no pixels, so a console that cannot decode + // has to stop asking before it can have a picture at all -- and since the ask is + // made by the RFB client at connect time, that means withdrawing the encodings and + // opening a new connection. + Log.w(TAG, "this device has no H.264 decoder; falling back to the pixel path"); + h264Probe.onDecoderUnsupported(); + VncClient.setH264Advertised(false); + // Latched here rather than left to the next tick: the connection being torn down + // can still deliver a rect on its way out, and one that reached a pipeline still + // willing to try would fail the same way and ask for another reconnect. + var pipeline = h264; + if (pipeline != null) pipeline.disable(); + setStatusNote(getString(R.string.vnc_display_h264_fallback)); + reconnect(); + return; + } + Log.w(TAG, "the console's H.264 stream ended", cause); + // A downgrade the user watched happen is the only one worth naming as one. Anything + // that failed before there was ever a picture is noise about a thing nobody saw. + setStatusNote(wasLive ? getString(R.string.vnc_display_h264_fallback) : null); + } + } + + /** + * Hook for subclasses that have to move something when the decoder view appears or goes away. + * The default console has nothing to do here -- the two views share one geometry, written by + * the same viewport controller. [width] and [height] are the stream's, and zero when it is not + * live, for the console whose decoder view has to be letterboxed by hand. + */ + @SuppressWarnings("unused") + protected void onH264StreamChanged(boolean live, int width, int height) { + } + + /** + * Appends a note to the status line, or clears it. Kept beside the status text rather than + * replacing it: which transport is carrying the picture is a second fact about the same + * connection, and losing "connected 1280x720" to say it would be a worse trade. + */ + protected void setStatusNote(@Nullable String note) { + var next = note == null ? "" : note; + // A note that has not changed is not written again. The H.264 policy is read once a second + // and re-states its verdict every time, which without this would be a setText per second + // for the whole life of a console that has settled on the pixel path. + if (statusNote.equals(next)) return; + statusNote = next; + applyStatusText(); + } + + private void applyStatusText() { + tvStatus.setText(statusNote.isEmpty() + ? statusText : fmt("%s \u00b7 %s", statusText, statusNote)); + } + protected void setStatus(String text, VncStatus newStatus) { int color; if (newStatus == this.status) return; @@ -397,7 +733,8 @@ protected void setStatus(String text, VncStatus newStatus) { default: return; } - tvStatus.setText(text); + statusText = text; + applyStatusText(); this.status = newStatus; var indicator = new GradientDrawable(); indicator.setShape(OVAL); @@ -418,6 +755,12 @@ protected void hideConnectingOverlay() { @Override public boolean dispatchKeyEvent(@NonNull KeyEvent event) { int keyCode = event.getKeyCode(); + // A hardware-mouse right-click the framework (or OEM ROM) failed to see consumed gets + // synthesized as a mouse-sourced BACK key. Inside the VM display that must never navigate + // back - the right-click itself is delivered to the guest by the pointer handlers. + if (keyCode == android.view.KeyEvent.KEYCODE_BACK + && (event.getSource() & android.view.InputDevice.SOURCE_MOUSE) != 0) + return true; if (keyCode == KEYCODE_VOLUME_UP || keyCode == KEYCODE_VOLUME_DOWN) return super.dispatchKeyEvent(event); int keysym = androidKeyToXKeysym(keyCode); @@ -537,10 +880,26 @@ protected static boolean isModifierKey(int keyCode) { protected void toggleSoftKeyboard() { var imm = getSystemService(InputMethodManager.class); - if (imm != null && ivDisplay != null) { - ivDisplay.requestFocus(); - imm.showSoftInput(ivDisplay, 0); - } + if (imm == null || ivDisplay == null) return; + // Post so the fab-menu popup has finished tearing down: called inline right after the item + // click, the popup still owns the focus transition and showSoftInput lands before ivDisplay + // is the served view and does nothing. (The letterbox onClick path already has focus, but + // routing both through the same retry keeps them consistent.) + mainHandler.post(() -> tryShowKeyboard(imm, 15)); + } + + // showSoftInput() can return true for a view the IMM isn't serving yet and show nothing, so the + // success test is imm.isActive(view), retried on a short delay until the input connection is + // live. The last few rounds force the IME (some ROMs ignore the implicit request). + private void tryShowKeyboard(@NonNull InputMethodManager imm, int attemptsLeft) { + if (attemptsLeft <= 0 || isFinishing() || ivDisplay == null) return; + ivDisplay.requestFocusFromTouch(); + ivDisplay.requestFocus(); + int flag = attemptsLeft <= 3 + ? InputMethodManager.SHOW_FORCED : InputMethodManager.SHOW_IMPLICIT; + imm.showSoftInput(ivDisplay, flag); + if (ivDisplay.isFocused() && imm.isActive(ivDisplay)) return; + mainHandler.postDelayed(() -> tryShowKeyboard(imm, attemptsLeft - 1), 60); } protected VncDisplayView.TextCommitListener createTextCommitListener() { @@ -626,23 +985,42 @@ protected String generateVncUri(boolean local) { return sb.toString(); } + /** + * The one connection dialog: what to point a viewer at, and the handoff to one. + * + *

    It shows the {@code vnc://} URI for this device and, when the server is bound wider than + * loopback, the one another machine on the network would use -- the pair the separate "view + * URL" dialog used to show, which is why there is no longer a separate dialog: a bare + * {@code host:port} said nothing the URI does not already say. Connect hands the network URI + * to whatever app claims {@code vnc://}; the password rides in it as a query parameter, and + * the copy button is there for the viewers that ignore it.

    + */ protected void openWithExternalApp() { - var url = generateVncUri(false); - var host = resolveVncHost(false); - var target = fmt("%s:%d", host, vncPort); + var localUrl = generateVncUri(true); + var remoteUrl = generateVncUri(false); + boolean sameUrl = localUrl.equals(remoteUrl); boolean hasPassword = vncPassword != null && !vncPassword.isEmpty(); var view = getLayoutInflater().inflate(R.layout.dialog_vnc_external, null); - TextView etTarget = view.findViewById(R.id.et_target); + EditText etLocal = view.findViewById(R.id.et_local); + EditText etRemote = view.findViewById(R.id.et_remote); TextView etPassword = view.findViewById(R.id.et_password); + TextInputLayout tilRemote = view.findViewById(R.id.til_remote); TextInputLayout tilPassword = view.findViewById(R.id.til_password); - etTarget.setText(target); + etLocal.setText(localUrl); + CopyableField.setupReadOnly(etLocal, getString(R.string.vnc_external_hint_local)); + if (sameUrl) { + tilRemote.setVisibility(GONE); + } else { + etRemote.setText(remoteUrl); + CopyableField.setupReadOnly(etRemote, getString(R.string.vnc_external_hint_remote)); + } if (hasPassword) { etPassword.setText(vncPassword); } else { tilPassword.setVisibility(GONE); } DialogInterface.OnClickListener onConnect = (d, w) -> { - var intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + var intent = new Intent(Intent.ACTION_VIEW, Uri.parse(remoteUrl)); try { startActivity(intent); } catch (ActivityNotFoundException e) { @@ -657,41 +1035,8 @@ protected void openWithExternalApp() { if (hasPassword) builder.setNeutralButton(R.string.vnc_external_copy_password, null); var dialog = builder.show(); - if (hasPassword) dialog.getButton(BUTTON_NEUTRAL).setOnClickListener(v -> { - var cm = getSystemService(ClipboardManager.class); - if (cm == null) return; - cm.setPrimaryClip(ClipData.newPlainText("VNC Password", vncPassword)); - Toast.makeText(this, R.string.vnc_menu_url_copied, Toast.LENGTH_SHORT).show(); - }); - } - - protected void showVncUrl() { - var local = generateVncUri(true); - var remote = generateVncUri(false); - boolean sameUrl = local.equals(remote); - boolean hasPassword = vncPassword != null && !vncPassword.isEmpty(); - var view = getLayoutInflater().inflate(R.layout.dialog_vnc_url, null); - TextView etLocal = view.findViewById(R.id.et_local); - TextView etRemote = view.findViewById(R.id.et_remote); - TextView etPassword = view.findViewById(R.id.et_password); - TextInputLayout tilPassword = view.findViewById(R.id.til_password); - TextInputLayout tilRemote = view.findViewById(R.id.til_remote); - etLocal.setText(local); - if (sameUrl) { - tilRemote.setVisibility(GONE); - } else { - etRemote.setText(remote); - } - if (hasPassword) { - etPassword.setText(vncPassword); - } else { - tilPassword.setVisibility(GONE); - } - new MaterialAlertDialogBuilder(this) - .setTitle(R.string.vnc_menu_view_url) - .setView(view) - .setPositiveButton(android.R.string.ok, null) - .show(); + if (hasPassword) dialog.getButton(BUTTON_NEUTRAL).setOnClickListener(v -> + CopyableField.copy(this, vncPassword, getString(R.string.vnc_external_hint_password))); } protected void reconnect() { @@ -699,6 +1044,7 @@ protected void reconnect() { Log.w(TAG, "Executor already shut down, skipping reconnect"); return; } + stopH264(); running = false; reconnectAttempt = 0; if (vncClient != null) vncClient.requestStop(); @@ -724,10 +1070,7 @@ protected void reconnect() { protected boolean onMenuItemClicked(@NonNull MenuItem item) { int id = item.getItemId(); - if (id == R.id.menu_keyboard) { - toggleSoftKeyboard(); - return true; - } else if (id == R.id.menu_rotate) { + if (id == R.id.menu_rotate) { rotateScreen(); return true; } else if (id == R.id.menu_reconnect) { @@ -736,9 +1079,6 @@ protected boolean onMenuItemClicked(@NonNull MenuItem item) { } else if (id == R.id.menu_external) { openWithExternalApp(); return true; - } else if (id == R.id.menu_view_url) { - showVncUrl(); - return true; } return false; } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/VncClient.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/VncClient.java index 3975fc08..a2dbcc6a 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/VncClient.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/VncClient.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.ui.vm.display.vnc.base; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -21,6 +24,41 @@ public interface NativeCallback { @SuppressWarnings("unused") void onFramebufferUpdated(int x, int y, int w, int h); + + /** + * One encoding-50 rect, whole: the eight-byte header (u32 BE length, u32 BE flags) and the + * Annex-B payload behind it. Parsed on this side rather than in C so that there is one + * parser and a test can feed it the seam's literal bytes. + * + *

    [width] and [height] are the rect's, which for this encoding is the coded size of the + * picture inside it. Called on the message-loop thread, and blocking here is how + * backpressure reaches the server: the socket stops being drained.

    + */ + @SuppressWarnings("unused") + void onH264Rect(@NonNull byte[] rect, int width, int height); + + /** + * One 0x44564831 rect payload: the fixed four bytes of H264_SINGLE_PORT.md section 1. + * Called on the message-loop thread. + */ + @SuppressWarnings("unused") + void onDvhRect(@NonNull byte[] payload); + } + + /** + * Stops this process asking for the H.264 encodings on connections made from here on. + * + *

    Process-wide because libvncclient's extension list is, and because the one fact that + * justifies withdrawing them is process-wide too: a device with no {@code video/avc} decoder + * has none for any console. It matters that the withdrawal happens rather than being merely + * noted -- a client that asks for encoding 50 is served no pixels, so a console that cannot + * decode and keeps asking is a console showing a frozen picture.

    + * + *

    Takes effect at the next {@link #connect}; a connection already up keeps what it + * negotiated.

    + */ + public static void setH264Advertised(boolean advertised) { + nativeSetH264Advertised(advertised); } private long nativeHandle; @@ -87,6 +125,8 @@ public void disconnect() { } } + private static native void nativeSetH264Advertised(boolean advertised); + private static native long nativeCreate(); private static native boolean nativeConnect(long handle, String host, int port, String password, NativeCallback cb); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/VncDisplayView.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/VncDisplayView.java index 863a8d86..fefb4c29 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/VncDisplayView.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/base/VncDisplayView.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.ui.vm.display.vnc.base; import android.content.Context; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VMVncDisplayActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VMVncDisplayActivity.java index e177de75..cc7913b6 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VMVncDisplayActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VMVncDisplayActivity.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.ui.vm.display.vnc.display; import static android.view.Gravity.BOTTOM; @@ -17,6 +20,7 @@ import android.content.Context; import android.content.SharedPreferences; import android.graphics.Bitmap; +import android.graphics.RectF; import android.graphics.drawable.GradientDrawable; import android.util.TypedValue; import android.view.MenuItem; @@ -29,72 +33,211 @@ import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.TextView; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.core.graphics.Insets; import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; import androidx.core.view.WindowInsetsCompat; import com.google.android.material.button.MaterialButton; import com.google.android.material.floatingactionbutton.FloatingActionButton; +import org.json.JSONObject; + import cn.classfun.droidvm.R; +import cn.classfun.droidvm.display.INativeDisplayRootService; +import cn.classfun.droidvm.lib.daemon.DaemonConnection; import cn.classfun.droidvm.lib.ui.DragTouchListener; import cn.classfun.droidvm.lib.ui.MaterialMenu; +import cn.classfun.droidvm.ui.vm.display.base.DaemonDisplayAttach; +import cn.classfun.droidvm.ui.vm.display.base.DisplayChromeController; +import cn.classfun.droidvm.ui.vm.display.base.DisplayExtraKeysPanel; +import cn.classfun.droidvm.ui.vm.display.base.DisplayKeyboardMenuRow; +import cn.classfun.droidvm.ui.vm.display.base.KeyboardMode; +import cn.classfun.droidvm.ui.vm.display.base.DisplayPhysicalKeyboardView; +import cn.classfun.droidvm.ui.vm.display.base.DisplaySource; +import cn.classfun.droidvm.ui.vm.display.base.DisplayViewportController; +import cn.classfun.droidvm.ui.vm.display.base.InputMode; +import cn.classfun.droidvm.ui.vm.display.base.PointerGestureTranslator; +import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.DirectInputSink; +import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.EvdevEncoder; +import cn.classfun.droidvm.ui.vm.display.nativedisplay.input.InputForwarder; import cn.classfun.droidvm.ui.vm.display.vnc.base.BaseVncActivity; public final class VMVncDisplayActivity extends BaseVncActivity { - private static final long AUTO_HIDE_DELAY_MS = 3000; private static final long OP_LABEL_HIDE_DELAY_MS = 2000; private static final String PREFS_NAME = "droidvm_prefs"; private static final String KEY_INPUT_MODE = "display_input_mode"; - private static final float TAP_SLOP = 20f; - private static final long TAP_TIMEOUT = 250; - private static final long DOUBLE_TAP_TIMEOUT = 300; - private static final float DEFAULT_ZOOM = 1f; - private static final float MIN_ZOOM = 0.5f; - private static final float MAX_ZOOM = 5f; - private static final float SNAP_THRESHOLD = 15f; - private static final float MIN_SCALE_DIST = 24f; - private static final float SCROLL_THRESHOLD = 8f; + // Chrome memory, shared with the native path: extra-keys on/off per typing surface + whether + // the physical keyboard is up. + private static final String KEY_KEYBOARD_MODE = "display_keyboard_mode"; + private static final String KEY_ZONE_EXTRA = "display_keyboard_zone_extra"; + private static final String KEY_ZONE_FNX = "display_keyboard_zone_fnx"; + // RFB pointer button-mask bits (the crosvm VNC server is fixed to tablet mode: an absolute + // pointer with these buttons plus scroll pulses). private static final int MASK_LEFT = 1; private static final int MASK_MIDDLE = 2; private static final int MASK_RIGHT = 4; private static final int MASK_SCROLL_UP = 8; private static final int MASK_SCROLL_DOWN = 16; - private enum InputMode {TOUCH, MOUSE} private LinearLayout statusBar; private MaterialButton btnFullscreen; private FrameLayout displayContainer; private FloatingActionButton fabMenu; private TextView operationLabel; - private boolean isFullscreen = false; - private boolean extraKeysVisible = true; + private DisplayPhysicalKeyboardView phyKeyboard; + // Per-mode input routing: whatever the VNC channel natively has goes over RFB (TABLET's + // absolute pointer + the keyboard); the rest goes to the crosvm --input evdev devices via the + // daemon (MOUSE = relative motion the guest renders a cursor for, TOUCH = raw multi-touch). + // What the RFB half lands on is this screen's own tablet and keyboard, built by crosvm behind + // this binding's VNC server -- so this console is an ordinary RFB client for those two, no + // different from TigerVNC on the same port, and the coordinate is read against this screen's + // geometry rather than some VM-wide pointer's. Both are absent when the screen's input switch + // is off (view-only); see setInputMode. + // Seeded from the shared pref in onSetupActivity(); TOUCH only until that read. private InputMode inputMode = InputMode.TOUCH; private SharedPreferences prefs; - private float cursorX, cursorY; - private int baseViewW, baseViewH; - private float zoom = DEFAULT_ZOOM; - private float panX, panY; - private int gestureMaxPointers; - private boolean gestureMoved; - private long gestureStartTime; - private float gestureStartMidX, gestureStartMidY; - private long lastTapTime; - private int lastTapFingerCount; - private float lastTouchX, lastTouchY; - private float lastMidX, lastMidY; - private float initialAngle; - private float rotationBase; - private float initialDist; - private float initialZoom; - private float lastScrollMidY; + + private InputForwarder inputForwarder; + private PointerGestureTranslator gestureTranslator; + private int rfbMask; // current RFB button mask (tablet mode) + private int rfbLastX, rfbLastY; // last absolute pointer position sent, fb px + private float mouseRemX, mouseRemY; // fractional remainders of relative mouse motion + // Single sources of truth for viewport geometry (fit/zoom/pan across display-area changes) + // and chrome visibility (fullscreen / extra keys). See the controller classes for the rules. + private DisplayViewportController viewport; + private DisplayChromeController chrome; + // Display areas smaller than this (e.g. landscape with a tall IME) freeze the viewport + // instead of re-laying it out; see DisplayViewportController. + private static final int MIN_AREA_DP = 96; + // Last mouse right/middle-button activity: any BACK key arriving shortly after is the + // framework's (or OEM's) right-click fallback and must not navigate away from the VM. + private long lastMouseButtonMs; + private static final long MOUSE_BACK_SUPPRESS_MS = 800; + + // Daemon broker binder plumbing (shared DaemonDisplayAttach): the direct sink turns each + // evdev frame into one binder call instead of the (much slower) vm_input JSON-RPC round-trip. + // Input works immediately on the RPC fallback and upgrades in place once the binder arrives; + // written on the main thread, read on the InputForwarder worker (hence volatile). + private volatile DirectInputSink directSink; + private DaemonDisplayAttach displayAttach; + // Display-source adapter: framebuffer events flow through the shared DisplaySource interface. + private VncBitmapSource displaySource; private final Runnable hideOperationLabel = () -> { if (operationLabel != null) operationLabel.setVisibility(GONE); }; + // Unified MOUSE/TABLET gestures. TABLET lands on the RFB channel (this binding's own + // absolute-tablet pointer); MOUSE lands on the crosvm relative-mouse device via vm_input. + private final PointerGestureTranslator.Listener gestureListener = + new PointerGestureTranslator.Listener() { + @Override + public void onRelativeMove(float dxGuest, float dyGuest) { + if (inputForwarder == null) return; + mouseRemX += dxGuest; + mouseRemY += dyGuest; + int dx = (int) mouseRemX, dy = (int) mouseRemY; + if (dx == 0 && dy == 0) return; + mouseRemX -= dx; + mouseRemY -= dy; + inputForwarder.sendMouseMove(dx, dy); + } + + @Override + public void onAbsoluteMove(float xGuest, float yGuest) { + rfbMove(Math.round(xGuest), Math.round(yGuest)); + } + + @Override + public void onLeftButton(boolean down, float xGuest, float yGuest) { + if (inputMode == InputMode.TABLET) { + rfbButton(MASK_LEFT, down, Math.round(xGuest), Math.round(yGuest)); + } else if (inputForwarder != null) { + inputForwarder.sendPointerButton(EvdevEncoder.BTN_LEFT, down); + } + } + + @Override + public void onLeftTap(float xGuest, float yGuest) { + onLeftButton(true, xGuest, yGuest); + onLeftButton(false, xGuest, yGuest); + } + + @Override + public void onRightClick(float xGuest, float yGuest) { + if (inputMode == InputMode.TABLET) { + int x = Math.round(xGuest), y = Math.round(yGuest); + rfbButton(MASK_RIGHT, true, x, y); + rfbButton(MASK_RIGHT, false, x, y); + } else if (inputForwarder != null) { + inputForwarder.sendPointerButton(EvdevEncoder.BTN_RIGHT, true); + inputForwarder.sendPointerButton(EvdevEncoder.BTN_RIGHT, false); + } + } + + @Override + public void onScroll(int vNotches, int hNotches) { + if (inputMode == InputMode.TABLET) { + rfbScroll(vNotches); + } else if (inputForwarder != null) { + inputForwarder.sendScroll(vNotches, hNotches); + } + } + + @Override + public void onZoomPan(float scaleFactor, float dxView, float dyView, + float focusX, float focusY) { + if (viewport != null) viewport.onZoomPan(scaleFactor, dxView, dyView); + } + }; + + // ---- RFB tablet-pointer helpers (absolute position + button mask) ---- + + private void rfbMove(int x, int y) { + if (vncClient == null || !vncClient.isConnected() || fbWidth <= 0) return; + rfbLastX = max(0, min(x, fbWidth - 1)); + rfbLastY = max(0, min(y, fbHeight - 1)); + vncClient.sendPointer(rfbLastX, rfbLastY, rfbMask); + } + + private void rfbButton(int maskBit, boolean down, int x, int y) { + if (vncClient == null || !vncClient.isConnected() || fbWidth <= 0) return; + rfbLastX = max(0, min(x, fbWidth - 1)); + rfbLastY = max(0, min(y, fbHeight - 1)); + rfbMask = down ? (rfbMask | maskBit) : (rfbMask & ~maskBit); + vncClient.sendPointer(rfbLastX, rfbLastY, rfbMask); + } + + /** RFB has no wheel axis; each notch is a scroll-button press/release pulse. */ + private void rfbScroll(int vNotches) { + if (vncClient == null || !vncClient.isConnected() || vNotches == 0) return; + int bit = vNotches > 0 ? MASK_SCROLL_UP : MASK_SCROLL_DOWN; + for (int i = 0; i < Math.abs(vNotches); i++) { + vncClient.sendPointer(rfbLastX, rfbLastY, rfbMask | bit); + vncClient.sendPointer(rfbLastX, rfbLastY, rfbMask); + } + } + + // Ships evdev records for MOUSE/TOUCH modes to the daemon, which owns the crosvm --input + // sockets. Runs on the InputForwarder worker thread (synchronous request keeps ordering). + private boolean sendInputToDaemon(int channel, @NonNull byte[] data) { + try { + var req = new JSONObject(); + req.put("command", "vm_input"); + req.put("vm_id", vmId); + req.put("screen", screenId); + req.put("channel", channel); + req.put("data", android.util.Base64.encodeToString(data, android.util.Base64.NO_WRAP)); + var resp = DaemonConnection.getInstance().request(req); + return resp.optBoolean("delivered", false); + } catch (Exception e) { + return false; + } + } @Override protected int getContentLayoutId() { @@ -113,25 +256,167 @@ protected void onBindExtraViews() { displayContainer = findViewById(R.id.display_container); fabMenu = findViewById(R.id.fab_menu); operationLabel = findViewById(R.id.tv_operation); + phyKeyboard = findViewById(R.id.phy_keyboard); } @Override protected void onSetupActivity() { prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - inputMode = InputMode.values()[prefs.getInt(KEY_INPUT_MODE, 0)]; + inputMode = InputMode.fromOrdinal(prefs.getInt(KEY_INPUT_MODE, 0)); + phyKeyboard.setKeyListener(vncExtraKeys); + // The physical keyboard's Shift/Ctrl/Alt/Win mirror the panel's sticky-modifier state. + extraKeysPanel.setModifierStateObserver(() -> phyKeyboard.refreshModifiers( + extraKeysPanel.isCtrlDown(), extraKeysPanel.isAltDown(), + extraKeysPanel.isShiftDown(), extraKeysPanel.isWinDown())); + extraKeysPanel.setZoneListener(new DisplayExtraKeysPanel.ZoneListener() { + @Override + public void onToggleFnxZone() { + chrome.toggleFnxZone(); + } + + @Override + public void onShowSystemKeyboard() { + toggleSoftKeyboard(); + } + }); + phyKeyboard.setZoneListener(new DisplayPhysicalKeyboardView.ZoneListener() { + @Override + public void onToggleExtraZone() { + chrome.toggleExtraZone(); + } + + @Override + public void onToggleFnxZone() { + chrome.toggleFnxZone(); + } + + @Override + public void onCloseKeyboard() { + chrome.setKeyboardMode(KeyboardMode.NONE); + } + }); setupCutoutMode(); - setupWindowInsets(); + setupLayoutControllers(); btnFullscreen.setOnClickListener(v -> toggleFullscreen()); + // The container's layout size IS the display area: chrome visibility, IME and rotation + // all funnel into it through normal layout. The viewport handles degenerate sizes itself. displayContainer.addOnLayoutChangeListener(( v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ) -> { int cw = right - left, ch = bottom - top; - if (cw > 0 && ch > 0) v.post(() -> updateAspectRatio(cw, ch)); + v.post(() -> viewport.setArea(cw, ch)); }); setupOperationLabel(); setupDisplayTouch(); setupFab(); + // Delegating sink: the direct daemon-binder path once attached, the vm_input JSON-RPC + // until then (and again if the binder dies). DirectInputSink falls back internally on + // any per-write failure, so input never drops during the upgrade. + inputForwarder = new InputForwarder((channel, data) -> { + var sink = directSink; + return sink != null ? sink.write(channel, data) : sendInputToDaemon(channel, data); + }); + gestureTranslator = new PointerGestureTranslator(mainHandler, gestureListener); + // Hardware mouse/stylus: hover is TABLET-only (RFB absolute move); wheel and right/middle + // buttons route per mode (tablet -> RFB mask, mouse -> crosvm mouse device). + ivDisplay.setOnHoverListener(this::onDisplayHover); + ivDisplay.setOnGenericMotionListener(this::onDisplayGenericMotion); + // Also on the container: a right-click over the letterbox area (outside the image) must + // still be consumed or the framework synthesizes BACK from it. + displayContainer.setOnGenericMotionListener(this::onDisplayGenericMotion); + // Keep the display area out of the system-gesture zones so multi-finger gestures + // (two-finger right-click/scroll, three-finger zoom) don't trip OEM gestures. + displayContainer.addOnLayoutChangeListener((v, l, t, r, b, ol, ot, or2, ob) -> + v.setSystemGestureExclusionRects(java.util.Collections.singletonList( + new android.graphics.Rect(0, 0, r - l, b - t)))); applyInputMode(); + + if (!vmId.isEmpty()) { + displayAttach = new DaemonDisplayAttach(this, mainHandler, + new DaemonDisplayAttach.Listener() { + @Override + public void onAttached(@NonNull INativeDisplayRootService service) { + directSink = new DirectInputSink(vmId, () -> screenId, service, + VMVncDisplayActivity.this::sendInputToDaemon); + } + + @Override + public void onLost() { + // Drop the direct sink so writes go back to the vm_input RPC. + directSink = null; + } + }); + displayAttach.start(); + } + } + + private boolean onDisplayHover(View v, MotionEvent event) { + if (inputMode != InputMode.TABLET) return false; + if (fbWidth <= 0 || v.getWidth() <= 0 || v.getHeight() <= 0) return false; + int action = event.getActionMasked(); + if (action == MotionEvent.ACTION_HOVER_MOVE || action == MotionEvent.ACTION_HOVER_ENTER) { + rfbMove(Math.round(event.getX() * fbWidth / v.getWidth()), + Math.round(event.getY() * fbHeight / v.getHeight())); + return true; + } + return false; + } + + // Button presses are ALWAYS consumed (every mode, letterbox included) - an unhandled + // BUTTON_SECONDARY press is what makes the framework synthesize a BACK key. + private boolean onDisplayGenericMotion(View v, MotionEvent event) { + switch (event.getActionMasked()) { + case MotionEvent.ACTION_SCROLL: { + int vN = Math.round(event.getAxisValue(MotionEvent.AXIS_VSCROLL)); + int hN = Math.round(event.getAxisValue(MotionEvent.AXIS_HSCROLL)); + if (inputMode == InputMode.TABLET) rfbScroll(vN); + else if (inputForwarder != null) inputForwarder.sendScroll(vN, hN); + return true; + } + case MotionEvent.ACTION_BUTTON_PRESS: + case MotionEvent.ACTION_BUTTON_RELEASE: { + lastMouseButtonMs = android.os.SystemClock.uptimeMillis(); + boolean down = event.getActionMasked() == MotionEvent.ACTION_BUTTON_PRESS; + // Map view coords to fb px; events from the container carry the letterbox offset. + float lx = event.getX(), ly = event.getY(); + if (v == displayContainer) { + lx -= ivDisplay.getLeft(); + ly -= ivDisplay.getTop(); + } + int ivW = ivDisplay.getWidth(), ivH = ivDisplay.getHeight(); + int x = ivW > 0 && fbWidth > 0 ? Math.round(lx * fbWidth / ivW) : rfbLastX; + int y = ivH > 0 && fbHeight > 0 ? Math.round(ly * fbHeight / ivH) : rfbLastY; + switch (event.getActionButton()) { + case MotionEvent.BUTTON_SECONDARY: + case MotionEvent.BUTTON_STYLUS_PRIMARY: + if (inputMode == InputMode.TABLET) rfbButton(MASK_RIGHT, down, x, y); + else if (inputForwarder != null) + inputForwarder.sendPointerButton(EvdevEncoder.BTN_RIGHT, down); + break; + case MotionEvent.BUTTON_TERTIARY: + if (inputMode == InputMode.TABLET) rfbButton(MASK_MIDDLE, down, x, y); + else if (inputForwarder != null) + inputForwarder.sendPointerButton(EvdevEncoder.BTN_MIDDLE, down); + break; + default: + break; + } + return true; + } + default: + return false; + } + } + + @Override + public boolean dispatchKeyEvent(@NonNull android.view.KeyEvent event) { + // OEM-injected right-click fallback BACK may claim a keyboard/virtual source, which the + // base class's mouse-source check misses; the timestamp catches it regardless. + if (event.getKeyCode() == android.view.KeyEvent.KEYCODE_BACK + && android.os.SystemClock.uptimeMillis() - lastMouseButtonMs + < MOUSE_BACK_SUPPRESS_MS) + return true; + return super.dispatchKeyEvent(event); } private void setupCutoutMode() { @@ -141,19 +426,104 @@ private void setupCutoutMode() { getWindow().setAttributes(params); } - private void setupWindowInsets() { + // Wires the viewport controller (single writer of the display-image geometry), the chrome + // controller (single writer of toolbar/status bar/extra keys/system bars visibility) and the + // window-insets listener that turns system bars + IME into content padding, which in turn + // sizes the display container. + private void setupLayoutControllers() { + int minAreaPx = Math.round(MIN_AREA_DP * getResources().getDisplayMetrics().density); + viewport = new DisplayViewportController(minAreaPx, + new DisplayViewportController.Listener() { + @Override + public void onViewportChanged(int baseW, int baseH, float viewScale, + float offsetX, float offsetY) { + place(ivDisplay, baseW, baseH, viewScale, offsetX, offsetY); + // The decoder view is the same rectangle, because it is showing the same + // screen. Applied here rather than mirrored later so that the two cannot drift + // apart across a rotation or an IME: there is one viewport and it writes both. + if (h264View != null) + place(h264View, baseW, baseH, viewScale, offsetX, offsetY); + } + + @Override + public void onGuestResizeWanted(int areaW, int areaH) { + // Auto-resize Guest Display: no guest-side channel on this path yet. + } + }); + + displaySource = new VncBitmapSource(new DisplaySource.Callbacks() { + @Override + public void onContentSize(int width, int height) { + viewport.setContentSize(width, height); + } + + @Override + public void onStateChanged(@NonNull DisplaySource.State state) { + // Status text and overlay are handled by BaseVncActivity's own status plumbing. + } + }); + + chrome = new DisplayChromeController( + KeyboardMode.fromName(prefs.getString(KEY_KEYBOARD_MODE, null)), + prefs.getBoolean(KEY_ZONE_EXTRA, true), + prefs.getBoolean(KEY_ZONE_FNX, false), + (fullscreen, mode, extraVisible, fnxVisible) -> { + toolbar.setVisibility(fullscreen ? GONE : VISIBLE); + statusBar.setVisibility(fullscreen ? GONE : VISIBLE); + extraKeysPanel.applyZones( + extraVisible, fnxVisible, mode == KeyboardMode.SYSTEM); + phyKeyboard.setZoneToggleState(extraVisible, fnxVisible); + phyKeyboard.setVisibleAnimated(mode == KeyboardMode.LAPTOP); + var controller = getWindow().getInsetsController(); + if (controller != null) { + if (fullscreen) { + controller.hide(WindowInsets.Type.systemBars()); + controller.setSystemBarsBehavior(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } else { + controller.show(WindowInsets.Type.systemBars()); + } + } + // Re-request insets so the content padding (and thus the display area) updates in + // the same pass as the visibility changes. + ViewCompat.requestApplyInsets(findViewById(android.R.id.content)); + }); + chrome.setStateListener((mode, extraVisible, fnxVisible) -> prefs.edit() + .putString(KEY_KEYBOARD_MODE, mode.name()) + .putBoolean(KEY_ZONE_EXTRA, extraVisible) + .putBoolean(KEY_ZONE_FNX, fnxVisible) + .apply()); + chrome.applyInitial(); + var content = (ViewGroup) findViewById(android.R.id.content); ViewCompat.setOnApplyWindowInsetsListener(content, (v, insets) -> { Insets sysBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()); Insets ime = insets.getInsets(WindowInsetsCompat.Type.ime()); - int top = isFullscreen ? 0 : sysBars.top; - int bottom = ime.bottom; + // The Main row's shared slot follows the IME: Fn while it is up, "show IME" while + // it is down. The system owns that visibility, so read it rather than track it. + extraKeysPanel.setImeVisible(insets.isVisible(WindowInsetsCompat.Type.ime())); + boolean fullscreen = chrome != null && chrome.isFullscreen(); + int top = fullscreen ? 0 : sysBars.top; + int bottom = Math.max(fullscreen ? 0 : sysBars.bottom, ime.bottom); v.setPadding(0, top, 0, bottom); return insets; }); ViewCompat.requestApplyInsets(content); } + /** + * Puts one view where the viewport says the guest's picture goes. A fresh LayoutParams per + * view, because two views sharing one instance is a layout that silently follows whichever was + * measured last. + */ + private static void place(@NonNull View target, int baseW, int baseH, float viewScale, + float offsetX, float offsetY) { + target.setLayoutParams(new FrameLayout.LayoutParams(baseW, baseH, CENTER)); + target.setScaleX(viewScale); + target.setScaleY(viewScale); + target.setTranslationX(offsetX); + target.setTranslationY(offsetY); + } + private float dp(float v) { return TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, v, getResources().getDisplayMetrics()); @@ -169,12 +539,7 @@ private void setupOperationLabel() { @Override protected void onFramebufferReady(int width, int height) { - updateAspectRatio(displayContainer.getWidth(), displayContainer.getHeight()); - if (inputMode == InputMode.MOUSE) { - if (cursorX < 0 || cursorX >= width) cursorX = width / 2f; - if (cursorY < 0 || cursorY >= height) cursorY = height / 2f; - ensureCursorVisible(); - } + displaySource.dispatchContentSize(width, height); } @Override @@ -182,56 +547,65 @@ protected void onBitmapUpdated(@NonNull Bitmap bitmap) { ivDisplay.setImageBitmap(bitmap); } - @Override - protected void onStatusChanged(String text, VncStatus status) { - mainHandler.removeCallbacks(this::hideBars); - if (!isFullscreen) showBars(); - } - @Override protected void onDestroyExtra() { - mainHandler.removeCallbacks(this::hideBars); mainHandler.removeCallbacks(hideOperationLabel); + if (inputForwarder != null) inputForwarder.close(); + var sink = directSink; + directSink = null; + if (sink != null) sink.close(); + if (displayAttach != null) { + displayAttach.stop(); + displayAttach = null; + } } + // Routes on-screen touches by input mode. TOUCH listens on ivDisplay, which is laid out to + // the framebuffer's aspect (DisplayViewportController), so view coords scale straight to the + // wire range. MOUSE/TABLET listen on the whole container; the gesture translator pins the + // coordinate-carrying gestures to the rendered image rect. private boolean onDisplayTouch(View v, MotionEvent event) { - if (vncClient == null || !vncClient.isConnected()) return false; if (fbWidth <= 0 || fbHeight <= 0) return false; - float viewX = event.getX(), viewY = event.getY(); - float ivW = v.getWidth(), ivH = v.getHeight(); - float imgAspect = (float) fbWidth / fbHeight; - float viewAspect = ivW / max(ivH, 1); - float drawnW, drawnH, offsetX, offsetY; - if (imgAspect > viewAspect) { - drawnW = ivW; - drawnH = ivW / imgAspect; - offsetX = 0; - offsetY = (ivH - drawnH) / 2; - } else { - drawnH = ivH; - drawnW = ivH * imgAspect; - offsetX = (ivW - drawnW) / 2; - offsetY = 0; + int ivW = ivDisplay.getWidth(), ivH = ivDisplay.getHeight(); + if (ivW <= 0 || ivH <= 0) return false; + // A hardware-mouse right/middle press also arrives on the touch stream (ACTION_DOWN with + // the button in buttonState). Those are delivered by the generic-motion handler; keep them + // out of the tap/gesture path (else right-click doubles as a left tap) but consume them so + // the framework doesn't synthesize a BACK key from an unhandled right-click. + if ((event.getSource() & android.view.InputDevice.SOURCE_MOUSE) != 0 + && (event.getButtonState() & (MotionEvent.BUTTON_SECONDARY + | MotionEvent.BUTTON_TERTIARY | MotionEvent.BUTTON_STYLUS_PRIMARY)) != 0) { + lastMouseButtonMs = android.os.SystemClock.uptimeMillis(); + return true; } - int vncX = (int) ((viewX - offsetX) / drawnW * fbWidth); - int vncY = (int) ((viewY - offsetY) / drawnH * fbHeight); - vncX = max(0, min(vncX, fbWidth - 1)); - vncY = max(0, min(vncY, fbHeight - 1)); - int mask; - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_MOVE: - mask = 1; - break; - case MotionEvent.ACTION_UP: - case MotionEvent.ACTION_CANCEL: - mask = 0; - break; + switch (inputMode) { + case TABLET: + case MOUSE: + // TABLET rides RFB, whose pointer coordinates are framebuffer px (crosvm's VNC + // server normalizes them itself); MOUSE REL deltas are guest px. Both therefore + // map the display rect onto the framebuffer size, NOT the normalized ABS range. + return gestureTranslator != null && gestureTranslator.onTouchEvent( + event, displayRectInContainer(), fbWidth, fbHeight); + case TOUCH: default: - return false; + if (inputForwarder == null) return false; + // The evdev multi-touch device advertises the fixed normalized ABS range + // (--input multi-touch with no width/height); scale view coords to that range. + inputForwarder.sendTouchEvent(event, + (float) EvdevEncoder.NORMALIZED_ABS_MAX / ivW, + (float) EvdevEncoder.NORMALIZED_ABS_MAX / ivH); + return true; } - vncClient.sendPointer(vncX, vncY, mask); - return true; + } + + // Where the framebuffer is rendered, in container coordinates: the letterbox-fitted image + // bounds mapped through the viewport's current zoom/pan transform. + @NonNull + private RectF displayRectInContainer() { + var rect = new RectF(0, 0, ivDisplay.getWidth(), ivDisplay.getHeight()); + ivDisplay.getMatrix().mapRect(rect); + rect.offset(ivDisplay.getLeft(), ivDisplay.getTop()); + return rect; } @SuppressLint("ClickableViewAccessibility") @@ -241,359 +615,54 @@ private void setupDisplayTouch() { @SuppressLint("ClickableViewAccessibility") private void applyInputMode() { - if (inputMode == InputMode.MOUSE) { - ivDisplay.setScaleType(ImageView.ScaleType.FIT_CENTER); + ivDisplay.setScaleType(ImageView.ScaleType.FIT_CENTER); + if (operationLabel != null) operationLabel.setVisibility(GONE); + if (inputMode == InputMode.MOUSE || inputMode == InputMode.TABLET) { + // Whole-container gesture surface: MOUSE is a borderless touchpad; TABLET pins the + // coordinate-carrying gestures to the rendered image inside the translator, so + // multi-finger gestures still work from the letterbox. ivDisplay.setClickable(false); ivDisplay.setOnTouchListener(null); displayContainer.setClickable(true); displayContainer.setOnClickListener(null); - displayContainer.setOnTouchListener(this::onMouseTouch); - if (operationLabel != null) operationLabel.setVisibility(GONE); - applyViewSize(); - applyViewTransform(); - ensureCursorVisible(); + displayContainer.setOnTouchListener(this::onDisplayTouch); } else { - ivDisplay.setScaleType(ImageView.ScaleType.FIT_CENTER); ivDisplay.setClickable(true); ivDisplay.setOnTouchListener(this::onDisplayTouch); displayContainer.setOnTouchListener(null); displayContainer.setClickable(true); + // Tap-to-summon-IME, unless the physical keyboard is the active typing surface. displayContainer.setOnClickListener(v -> { - showBars(); - toggleSoftKeyboard(); + if (chrome == null || chrome.getKeyboardMode() != KeyboardMode.LAPTOP) + toggleSoftKeyboard(); }); - if (operationLabel != null) operationLabel.setVisibility(GONE); } + if (gestureTranslator != null) { + gestureTranslator.setAbsolute(inputMode == InputMode.TABLET); + gestureTranslator.reset(); + } + if (inputForwarder != null) + inputForwarder.setInputMode(inputMode); } private void setInputMode(InputMode mode) { if (inputMode == mode) return; + // Both absolute modes are inert with the switch off, the same as on the native console. + // TOUCH because this screen's multi-touch device is not created; TABLET because the switch + // is what the daemon sends as view-only=true, and a view-only binding has no tablet and no + // keyboard behind it -- crosvm drops the RFB pointer and key events rather than injecting + // them. So RFB is no longer a way around this screen's switch, which it was while those + // devices belonged to the VM instead of to the binding. MOUSE is: the relative pointer has + // no output binding and is not a screen's to switch off. Say why once, here, rather than + // leaving the user tapping at nothing. + if (!screenInputEnabled && mode != InputMode.MOUSE) + Toast.makeText(this, R.string.display_input_disabled_hint, Toast.LENGTH_LONG).show(); inputMode = mode; prefs.edit().putInt(KEY_INPUT_MODE, mode.ordinal()).apply(); - resetViewTransform(); - if (mode == InputMode.MOUSE) { - if (fbWidth > 0) cursorX = fbWidth / 2f; - if (fbHeight > 0) cursorY = fbHeight / 2f; - } - applyViewSize(); + viewport.resetToFit(); applyInputMode(); } - private void resetViewTransform() { - zoom = DEFAULT_ZOOM; - panX = 0; - panY = 0; - ivDisplay.setTranslationX(0); - ivDisplay.setTranslationY(0); - ivDisplay.setRotation(0); - } - - private int currentViewW() { - return inputMode == InputMode.MOUSE - ? Math.round(baseViewW * zoom) : baseViewW; - } - - private int currentViewH() { - return inputMode == InputMode.MOUSE - ? Math.round(baseViewH * zoom) : baseViewH; - } - - private void applyViewSize() { - if (baseViewW <= 0 || baseViewH <= 0) return; - int w = currentViewW(), h = currentViewH(); - var lp = ivDisplay.getLayoutParams(); - if (lp instanceof FrameLayout.LayoutParams) { - ((FrameLayout.LayoutParams) lp).gravity = CENTER; - lp.width = w; - lp.height = h; - } else { - lp = new FrameLayout.LayoutParams(w, h, CENTER); - } - ivDisplay.setLayoutParams(lp); - } - - private void applyViewTransform() { - ivDisplay.setTranslationX(panX); - ivDisplay.setTranslationY(panY); - } - - private float midX(@NonNull MotionEvent e) { - float s = 0; - for (int i = 0; i < e.getPointerCount(); i++) s += e.getX(i); - return s / e.getPointerCount(); - } - - private float midY(@NonNull MotionEvent e) { - float s = 0; - for (int i = 0; i < e.getPointerCount(); i++) s += e.getY(i); - return s / e.getPointerCount(); - } - - @SuppressLint("ClickableViewAccessibility") - private boolean onMouseTouch(View v, MotionEvent event) { - if (vncClient == null || !vncClient.isConnected()) return false; - if (fbWidth <= 0 || fbHeight <= 0) return false; - int pc = event.getPointerCount(); - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - gestureMaxPointers = 1; - gestureMoved = false; - gestureStartTime = System.currentTimeMillis(); - gestureStartMidX = event.getX(); - gestureStartMidY = event.getY(); - lastTouchX = event.getX(); - lastTouchY = event.getY(); - return true; - case MotionEvent.ACTION_POINTER_DOWN: - gestureMaxPointers = max(gestureMaxPointers, pc); - // Re-anchor the tap reference to the multi-finger midpoint and - // restart the tap window. The ACTION_DOWN anchor was a single - // finger position, so the two-finger midpoint sits ~half a - // finger-spread away and would instantly trip gestureMoved, - // making a still two-finger tap (right click) impossible. - gestureStartMidX = midX(event); - gestureStartMidY = midY(event); - gestureStartTime = System.currentTimeMillis(); - gestureMoved = false; - if (pc == 2) initTwoFinger(event); - if (pc >= 3) lastScrollMidY = midY(event); - return true; - case MotionEvent.ACTION_MOVE: { - float mx = midX(event), my = midY(event); - float dist = (float) Math.hypot( - mx - gestureStartMidX, my - gestureStartMidY); - if (dist > TAP_SLOP) gestureMoved = true; - if (gestureMaxPointers >= 3 && pc >= 3) { - float dy = my - lastScrollMidY; - if (Math.abs(dy) > SCROLL_THRESHOLD) { - boolean up = dy < 0; - vncClient.sendPointer((int) cursorX, (int) cursorY, - up ? MASK_SCROLL_UP : MASK_SCROLL_DOWN); - vncClient.sendPointer((int) cursorX, (int) cursorY, 0); - showOperation(up - ? R.string.vnc_op_scroll_up - : R.string.vnc_op_scroll_down); - lastScrollMidY = my; - } - } else if (gestureMaxPointers >= 2 && pc >= 2) { - handleTwoFinger(event); - } else if (gestureMaxPointers <= 1 && pc == 1) { - float dx = event.getX() - lastTouchX; - float dy = event.getY() - lastTouchY; - lastTouchX = event.getX(); - lastTouchY = event.getY(); - if (dx != 0 || dy != 0) { - moveCursor(dx, dy); - showOperation(R.string.vnc_op_mouse_move); - } - } - return true; - } - case MotionEvent.ACTION_UP: - if (gestureMaxPointers >= 2) { - ivDisplay.setRotation(snapRotation(ivDisplay.getRotation())); - clampPan(); - ivDisplay.setTranslationX(panX); - ivDisplay.setTranslationY(panY); - updateOperationLabelPosition(); - } else { - ensureCursorVisible(); - } - if (!gestureMoved - && System.currentTimeMillis() - gestureStartTime < TAP_TIMEOUT) { - boolean dbl = lastTapFingerCount == gestureMaxPointers - && System.currentTimeMillis() - lastTapTime < DOUBLE_TAP_TIMEOUT; - handleTap(gestureMaxPointers, dbl); - lastTapTime = System.currentTimeMillis(); - lastTapFingerCount = gestureMaxPointers; - } else { - lastTapFingerCount = 0; - } - gestureMaxPointers = 0; - return true; - case MotionEvent.ACTION_CANCEL: - gestureMaxPointers = 0; - lastTapFingerCount = 0; - return true; - case MotionEvent.ACTION_POINTER_UP: - if (gestureMaxPointers == 2 && pc <= 2) - ivDisplay.setRotation(snapRotation(ivDisplay.getRotation())); - return true; - } - return false; - } - - private void handleTap(int fingerCount, boolean dbl) { - switch (fingerCount) { - case 1: - sendClick(MASK_LEFT); - if (dbl) sendClick(MASK_LEFT); - showOperation(dbl - ? R.string.vnc_op_left_double_click - : R.string.vnc_op_left_click); - break; - case 2: - sendClick(MASK_RIGHT); - if (dbl) sendClick(MASK_RIGHT); - showOperation(dbl - ? R.string.vnc_op_right_double_click - : R.string.vnc_op_right_click); - break; - default: - sendClick(MASK_MIDDLE); - if (dbl) sendClick(MASK_MIDDLE); - showOperation(dbl - ? R.string.vnc_op_middle_double_click - : R.string.vnc_op_middle_click); - break; - } - } - - private void sendClick(int mask) { - vncClient.sendPointer((int) cursorX, (int) cursorY, mask); - vncClient.sendPointer((int) cursorX, (int) cursorY, 0); - } - - private void initTwoFinger(@NonNull MotionEvent event) { - lastMidX = (event.getX(0) + event.getX(1)) / 2f; - lastMidY = (event.getY(0) + event.getY(1)) / 2f; - initialAngle = twoFingerAngle(event); - rotationBase = ivDisplay.getRotation(); - initialDist = twoFingerDistance(event); - initialZoom = zoom; - } - - private void handleTwoFinger(@NonNull MotionEvent event) { - float mx = (event.getX(0) + event.getX(1)) / 2f; - float my = (event.getY(0) + event.getY(1)) / 2f; - panX += mx - lastMidX; - panY += my - lastMidY; - lastMidX = mx; - lastMidY = my; - clampPan(); - ivDisplay.setTranslationX(panX); - ivDisplay.setTranslationY(panY); - float angle = twoFingerAngle(event); - float dAngle = normalizeAngle(angle - initialAngle); - ivDisplay.setRotation(snapNear(rotationBase + dAngle)); - float dist = twoFingerDistance(event); - if (initialDist > MIN_SCALE_DIST) { - float nz = max(MIN_ZOOM, min(initialZoom * dist / initialDist, MAX_ZOOM)); - if (nz != zoom) { - zoom = nz; - applyViewSize(); - } - } - updateOperationLabelPosition(); - } - - private void clampPan() { - int cW = displayContainer.getWidth(); - int cH = displayContainer.getHeight(); - int vW = currentViewW(); - int vH = currentViewH(); - panX = max(-(cW + vW) / 2f, min(panX, (cW + vW) / 2f)); - panY = max(-(cH + vH) / 2f, min(panY, (cH + vH) / 2f)); - } - - private float twoFingerAngle(@NonNull MotionEvent e) { - float dx = e.getX(1) - e.getX(0); - float dy = e.getY(1) - e.getY(0); - return (float) Math.toDegrees(Math.atan2(dy, dx)); - } - - private float twoFingerDistance(@NonNull MotionEvent e) { - float dx = e.getX(1) - e.getX(0); - float dy = e.getY(1) - e.getY(0); - return (float) Math.hypot(dx, dy); - } - - private float normalizeAngle(float a) { - while (a > 180) a -= 360; - while (a < -180) a += 360; - return a; - } - - private float snapNear(float deg) { - float snapped = Math.round(deg / 90f) * 90f; - if (Math.abs(deg - snapped) <= SNAP_THRESHOLD) return snapped; - return deg; - } - - private float snapRotation(float deg) { - return Math.round(deg / 90f) * 90f; - } - - private void moveCursor(float dx, float dy) { - if (fbWidth <= 0 || fbHeight <= 0) return; - double rad = Math.toRadians(ivDisplay.getRotation()); - float cos = (float) Math.cos(rad), sin = (float) Math.sin(rad); - float vncDx = dx * cos + dy * sin; - float vncDy = -dx * sin + dy * cos; - cursorX = max(0, min(cursorX + vncDx, fbWidth - 1)); - cursorY = max(0, min(cursorY + vncDy, fbHeight - 1)); - vncClient.sendPointer((int) cursorX, (int) cursorY, 0); - ensureCursorVisible(); - } - - private void ensureCursorVisible() { - if (fbWidth <= 0 || fbHeight <= 0) return; - int cW = displayContainer.getWidth(); - int cH = displayContainer.getHeight(); - int viewW = currentViewW(); - int viewH = currentViewH(); - if (cW <= 0 || cH <= 0 || viewW <= 0 || viewH <= 0) return; - double rad = Math.toRadians(ivDisplay.getRotation()); - float cos = (float) Math.cos(rad), sin = (float) Math.sin(rad); - float localX = cursorX * viewW / (float) fbWidth; - float localY = cursorY * viewH / (float) fbHeight; - float relX = localX - viewW / 2f; - float relY = localY - viewH / 2f; - float rotX = relX * cos - relY * sin; - float rotY = relX * sin + relY * cos; - float screenX = cW / 2f + panX + rotX; - float screenY = cH / 2f + panY + rotY; - if (screenX < 0) panX -= screenX; - else if (screenX > cW) panX -= (screenX - cW); - if (screenY < 0) panY -= screenY; - else if (screenY > cH) panY -= (screenY - cH); - clampPan(); - ivDisplay.setTranslationX(panX); - ivDisplay.setTranslationY(panY); - updateOperationLabelPosition(); - } - - private void updateOperationLabelPosition() { - if (operationLabel == null) return; - int cW = displayContainer.getWidth(); - int cH = displayContainer.getHeight(); - if (cW <= 0 || cH <= 0) return; - float r = ivDisplay.getRotation(); - operationLabel.setRotation(r); - int deg = ((Math.round(r / 90f) % 4) + 4) % 4; - var lp = (FrameLayout.LayoutParams) operationLabel.getLayoutParams(); - int m = (int) dp(8); - switch (deg) { - case 0: - lp.gravity = TOP | CENTER_HORIZONTAL; - lp.setMargins(0, m, 0, 0); - break; - case 1: - lp.gravity = END | CENTER_VERTICAL; - lp.setMargins(0, 0, m, 0); - break; - case 2: - lp.gravity = BOTTOM | CENTER_HORIZONTAL; - lp.setMargins(0, 0, 0, m); - break; - default: - lp.gravity = START | CENTER_VERTICAL; - lp.setMargins(m, 0, 0, 0); - break; - } - operationLabel.setLayoutParams(lp); - } - private void showOperation(int resId) { if (operationLabel == null) return; operationLabel.setText(resId); @@ -602,56 +671,8 @@ private void showOperation(int resId) { mainHandler.postDelayed(hideOperationLabel, OP_LABEL_HIDE_DELAY_MS); } - private void updateAspectRatio(int containerW, int containerH) { - if (containerW <= 0 || containerH <= 0 || fbWidth <= 0 || fbHeight <= 0) return; - float vmAspect = (float) fbWidth / fbHeight; - float containerAspect = (float) containerW / containerH; - if (vmAspect > containerAspect) { - baseViewW = containerW; - baseViewH = Math.round(containerW / vmAspect); - } else { - baseViewH = containerH; - baseViewW = Math.round(containerH * vmAspect); - } - applyViewSize(); - if (inputMode == InputMode.MOUSE) ensureCursorVisible(); - } - - private void showBars() { - toolbar.setVisibility(VISIBLE); - statusBar.setVisibility(VISIBLE); - if (status == VncStatus.CONNECTED) - mainHandler.postDelayed(this::hideBars, AUTO_HIDE_DELAY_MS); - } - - private void hideBars() { - if (isFullscreen) return; - toolbar.setVisibility(GONE); - statusBar.setVisibility(GONE); - } - private void toggleFullscreen() { - isFullscreen = !isFullscreen; - var controller = getWindow().getInsetsController(); - if (controller == null) return; - if (isFullscreen) { - mainHandler.removeCallbacks(this::hideBars); - toolbar.setVisibility(GONE); - statusBar.setVisibility(GONE); - extraKeysPanel.setVisibility(GONE); - controller.hide(WindowInsets.Type.systemBars()); - controller.setSystemBarsBehavior(BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); - } else { - showBars(); - if (extraKeysVisible) extraKeysPanel.animateIn(); - controller.show(WindowInsets.Type.systemBars()); - } - ViewCompat.requestApplyInsets(findViewById(android.R.id.content)); - } - - private void toggleExtraKeys() { - extraKeysVisible = !extraKeysVisible; - extraKeysPanel.setVisibleAnimated(extraKeysVisible); + chrome.toggleFullscreen(); } @SuppressLint("ClickableViewAccessibility") @@ -663,34 +684,74 @@ private void setupFab() { private void showFabMenu() { var popup = new MaterialMenu(this, fabMenu); popup.inflate(R.menu.menu_vnc_display_menu); - var item = popup.getMenu().findItem(R.id.menu_input_mode); - if (item != null) { - if (inputMode == InputMode.TOUCH) { - item.setTitle(R.string.vnc_menu_input_mode_mouse); - item.setIcon(R.drawable.ic_mouse); - } else { - item.setTitle(R.string.vnc_menu_input_mode_touch); - item.setIcon(R.drawable.ic_touchpad); - } - } + var header = new LinearLayout(this); + header.setOrientation(LinearLayout.VERTICAL); + header.addView(buildInputModeHeader(popup)); + header.addView(DisplayKeyboardMenuRow.build( + getLayoutInflater(), chrome.getKeyboardMode(), this::applyKeyboardMode, + popup::dismiss)); + popup.setHeaderView(header); popup.setOnMenuItemClickListener(this::onMenuItemClicked); popup.show(); } + // Menu header: one row of three icon buttons (touch / tablet / mouse), active mode checked. + private View buildInputModeHeader(MaterialMenu popup) { + var group = (com.google.android.material.button.MaterialButtonToggleGroup) + getLayoutInflater().inflate(R.layout.view_input_mode_toggle, null); + group.check(inputMode == InputMode.MOUSE ? R.id.mode_mouse + : inputMode == InputMode.TABLET ? R.id.mode_tablet : R.id.mode_touch); + group.addOnButtonCheckedListener((g, checkedId, isChecked) -> { + if (!isChecked) return; + setInputMode(checkedId == R.id.mode_mouse ? InputMode.MOUSE + : checkedId == R.id.mode_tablet ? InputMode.TABLET : InputMode.TOUCH); + popup.dismiss(); + }); + return group; + } + + // Selecting the system keyboard summons the IME; anything else puts it away, so the mode + // and what is actually on screen agree. + private void applyKeyboardMode(@NonNull KeyboardMode mode) { + chrome.setKeyboardMode(mode); + if (mode == KeyboardMode.SYSTEM) toggleSoftKeyboard(); + else hideSoftKeyboard(); + } + + // Dropping the display view's focus first matters: it is what the IME is attached to, and + // some ROMs re-show the keyboard for a still-focused target right after a hide request. + private void hideSoftKeyboard() { + ivDisplay.clearFocus(); + var controller = WindowCompat.getInsetsController(getWindow(), ivDisplay); + controller.hide(WindowInsetsCompat.Type.ime()); + var imm = getSystemService(android.view.inputmethod.InputMethodManager.class); + if (imm != null) + imm.hideSoftInputFromWindow(displayContainer.getWindowToken(), 0); + } + @Override protected boolean onMenuItemClicked(@NonNull MenuItem item) { int id = item.getItemId(); - if (id == R.id.menu_extra_keys) { - toggleExtraKeys(); - return true; - } else if (id == R.id.menu_fullscreen) { + if (id == R.id.menu_fullscreen) { toggleFullscreen(); return true; - } else if (id == R.id.menu_input_mode) { - setInputMode(inputMode == InputMode.TOUCH - ? InputMode.MOUSE : InputMode.TOUCH); + } else if (id == R.id.menu_project_external) { + projectToExternalDisplay(); return true; } return super.onMenuItemClicked(item); } + + // Projecting this screen onto an external display is the presentation console, reached from + // in here rather than from the VM's console chooser: it is an action on the screen already + // open, not a separate console to pick. The presentation activity puts up the display picker + // itself and shows nothing until one is chosen, so "pick a display, then land on it" is its + // own flow -- this only hands it the same vm/screen this console is already bound to. + private void projectToExternalDisplay() { + var intent = new android.content.Intent(this, VMVncPresentationActivity.class); + intent.putExtra(EXTRA_VM_ID, vmId); + intent.putExtra(EXTRA_VM_NAME, vmName); + intent.putExtra(EXTRA_SCREEN, screenId); + startActivity(intent); + } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VMVncPresentationActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VMVncPresentationActivity.java index f76cfa08..c19a6b23 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VMVncPresentationActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VMVncPresentationActivity.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.ui.vm.display.vnc.display; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; @@ -48,13 +51,27 @@ public void onDisplayChanged(int displayId) { @Override public void onDisplayRemoved(int displayId) { if (pres != null && pres.getDisplayId() == displayId) { - pres.dismiss(); - pres = null; + dismissPresentation(); Toast.makeText(this, R.string.display_lost, Toast.LENGTH_SHORT).show(); finish(); } } + /** + * Drops the presentation window, and the decoder that was drawing into it first. + * + *

    Order matters and only in this direction: the decoder's Surface belongs to a view in that + * window, so dismissing first would leave the pipeline holding a Surface whose window is gone + * and a socket the server still counts as its one client.

    + */ + private void dismissPresentation() { + setH264View(null); + if (pres != null) { + pres.dismiss(); + pres = null; + } + } + @Override protected int getContentLayoutId() { return R.layout.activity_vm_vnc_presentation; @@ -171,14 +188,22 @@ protected void onClearDisplay() { @Override protected void onDestroyExtra() { stopInputCapture(); - if (pres != null) { - pres.dismiss(); - pres = null; - } + dismissPresentation(); if (displayManager != null) displayManager.unregisterDisplayListener(this); } + /** + * The stream's picture is the presentation's picture, so the decoder view is fitted to it the + * way the RFB {@link android.widget.ImageView} beneath is fitted by its scale type. Nothing + * else about the H.264 path is different here -- the probe, the fallback, the retry ladder and + * the liveness timeout are all the base console's, working against a view on another display. + */ + @Override + protected void onH264StreamChanged(boolean live, int width, int height) { + if (pres != null) pres.fitH264(live ? width : 0, live ? height : 0); + } + @Override protected void onVncClientCreated() { vncTouchPad.setVncClient(vncClient); @@ -205,6 +230,11 @@ private void startPresentationOnTarget() { return; } startInputCapture(); + // The decoder's view exists only now, with the window that holds it -- which is why this is + // not in onSetupActivity: until a display has been chosen there is nowhere for a decoded + // frame to go. Nothing to ask for afterwards: the stream, if there is one, has been + // arriving on the RFB connection all along, and the next rect finds this pipeline. + setH264View(pres.getH264View()); synchronized (bitmapLock) { if (displayBitmap != null && !displayBitmap.isRecycled()) pres.updateBitmap(displayBitmap); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VncBitmapSource.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VncBitmapSource.java new file mode 100644 index 00000000..d73be2a9 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/display/VncBitmapSource.java @@ -0,0 +1,51 @@ +// 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.ui.vm.display.vnc.display; + +import androidx.annotation.NonNull; + +import cn.classfun.droidvm.ui.vm.display.base.DisplaySource; + +/** + * {@link DisplaySource} adapter for the VNC display path. The RFB connection, framebuffer bitmap + * and reconnect logic live in BaseVncActivity (which owns the whole connection lifecycle, so + * {@link #start()}/{@link #shutdown()} are no-ops here); the activity feeds framebuffer events in + * through the dispatch methods. This keeps the VNC console speaking the same source language as + * the native path until the connection plumbing itself moves in here with the base-activity + * unification. No guest-resize channel (would be RFB desktop-size) yet. + */ +public final class VncBitmapSource implements DisplaySource { + private final Callbacks callbacks; + + public VncBitmapSource(@NonNull Callbacks callbacks) { + this.callbacks = callbacks; + } + + /** BaseVncActivity's onFramebufferReady hook lands here. */ + public void dispatchContentSize(int width, int height) { + callbacks.onContentSize(width, height); + } + + /** BaseVncActivity's status hook lands here (currently informational only). */ + public void dispatchState(@NonNull State state) { + callbacks.onStateChanged(state); + } + + @Override + public void start() { + } + + @Override + public void shutdown() { + } + + @Override + public boolean supportsGuestResize() { + return false; + } + + @Override + public void requestGuestResize(int width, int height) { + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264ConsoleDecoder.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264ConsoleDecoder.java new file mode 100644 index 00000000..6b094d09 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264ConsoleDecoder.java @@ -0,0 +1,275 @@ +// 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.ui.vm.display.vnc.h264; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.media.MediaCodec; +import android.media.MediaFormat; +import android.os.HandlerThread; +import android.util.Log; +import android.view.Surface; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.util.ArrayDeque; + +/** + * The stream's frames, decoded straight onto a Surface. + * + *

    This is a live screen, not a recording, so every decision here trades smoothness for latency. + * Output buffers are released for display the moment they exist rather than at a presentation time, + * the format asks for the platform's low-latency mode, and timestamps are a counter in arrival + * order -- there is no clock to be faithful to when the thing being shown is happening now.

    + * + *

    Asynchronous mode, so that a decoder holding a frame back does not also stop the reader from + * pulling the next one off the socket, and so that a finished frame reaches the screen without + * anything having to come back and ask for it. That last part is what a synchronous read-feed-drain + * loop gets wrong on exactly the case this pipeline is built for: with the guest idle, the last + * frame before the silence would sit undrained until the silence ended.

    + */ +public final class H264ConsoleDecoder { + private static final String TAG = "H264ConsoleDecoder"; + private static final String MIME = "video/avc"; + /** + * How many frames may wait for an input buffer before the submitting thread blocks. Small: the + * queue exists to cover a momentary hiccup, and anything longer is latency being accumulated + * rather than absorbed. Blocking is what pushes the backpressure back down the socket. + */ + private static final int MAX_PENDING = 8; + /** How long a submit may block before the stream is declared beyond saving. */ + private static final long SUBMIT_TIMEOUT_MS = 2000; + /** Timestamp step, in microseconds. Only its monotonicity matters. */ + private static final long PTS_STEP_US = 1000; + + /** + * Reported on the codec's own thread, or on whichever thread was feeding it when it broke, and + * possibly while this decoder's lock is held -- so implementations post the news somewhere and + * return rather than tearing anything down inline. + */ + public interface Listener { + void onDecoderFailed(@NonNull Exception cause); + } + + private final Listener listener; + private final Object lock = new Object(); + private final ArrayDeque freeInputs = new ArrayDeque<>(); + private final ArrayDeque pending = new ArrayDeque<>(); + private HandlerThread codecThread; + private MediaCodec codec; + private long nextPtsUs; + private boolean released; + private boolean failed; + + public H264ConsoleDecoder(@NonNull Listener listener) { + this.listener = listener; + } + + /** + * Configures and starts the decoder against [surface]. False means this device could not stand + * one up at all, which is a fallback to RFB rather than an error to show. + */ + public boolean start(@NonNull Surface surface, int width, int height) { + synchronized (lock) { + if (released) return false; + } + try { + var format = MediaFormat.createVideoFormat(MIME, width, height); + // The decoder's own guess at a maximum input size is derived from the resolution and a + // compression ratio no encoder promises. A sync frame of a busy desktop can beat it, + // and the symptom is one overflowing frame rather than a refusal, so ask for room. + format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, + Math.max(width * height, 1 << 20)); + // Ask the decoder not to build a reordering pipeline it would have to fill before + // emitting anything. The stream has no B frames to reorder, and on this path a frame + // held back for smoothness is a frame the user is waiting on. + format.setInteger(MediaFormat.KEY_LOW_LATENCY, 1); + codecThread = new HandlerThread("h264-decoder"); + codecThread.start(); + codec = MediaCodec.createDecoderByType(MIME); + codec.setCallback(new Callback(), new android.os.Handler(codecThread.getLooper())); + codec.configure(format, surface, null, 0); + codec.start(); + Log.i(TAG, fmt("decoder up for %dx%d", width, height)); + return true; + } catch (Exception e) { + Log.w(TAG, "could not start the H.264 decoder", e); + release(); + return false; + } + } + + /** + * Hands one frame to the decoder, blocking while the queue is full. + * + *

    Called from the RFB message loop, and the blocking is the point: a stalled decoder stops + * that loop, the loop stops draining the socket, and the server stops being asked for frames. + * That chain is what keeps a slow device showing late frames rather than wrong ones.

    + * + * @throws IOException when the decoder has failed or stopped keeping up, which the caller turns + * into the end of the stream and thus a fallback to RFB. + */ + public void submit(@NonNull byte[] frame) throws IOException { + synchronized (lock) { + var deadline = System.currentTimeMillis() + SUBMIT_TIMEOUT_MS; + while (!released && !failed && pending.size() >= MAX_PENDING) { + var left = deadline - System.currentTimeMillis(); + if (left <= 0) throw new IOException("the H.264 decoder stopped keeping up"); + try { + lock.wait(left); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted while feeding the H.264 decoder"); + } + } + if (failed) throw new IOException("the H.264 decoder failed"); + if (released) throw new IOException("the H.264 decoder is gone"); + pending.add(frame); + pump(); + } + } + + /** + * Throws away everything the codec was holding, so the next frame starts a picture rather than + * continuing one. + * + *

    What the reset flags on an encoding-50 rect ask for. A viewer that keeps one decoder + * context per rectangle has several things to throw away; this console has one codec, so both + * flags mean this. The frames already queued go with it on purpose: they belong to the stream + * that was, and the bytes arriving behind the flag are a sync frame that does not need them. + * + *

    {@code start()} after {@code flush()} is not optional in asynchronous mode -- a flushed + * codec stops calling back until it is started again, and every input buffer index handed out + * before the flush is invalid, which is why both queues are emptied here rather than drained. + */ + public void reset() { + synchronized (lock) { + if (released || failed || codec == null) return; + try { + codec.flush(); + freeInputs.clear(); + pending.clear(); + codec.start(); + nextPtsUs = 0; + lock.notifyAll(); + } catch (Exception e) { + fail(e); + } + } + } + + /** Tears the decoder down. Safe to call more than once, and from any thread. */ + public void release() { + MediaCodec doomed; + HandlerThread thread; + synchronized (lock) { + if (released) return; + released = true; + doomed = codec; + thread = codecThread; + codec = null; + codecThread = null; + pending.clear(); + freeInputs.clear(); + lock.notifyAll(); + } + if (doomed != null) { + try { + doomed.stop(); + } catch (Exception ignored) { + // A codec that already failed refuses to stop; it still has to be released. + } + try { + doomed.release(); + } catch (Exception ignored) { + // Nothing left to do about a codec that will not let go. + } + } + if (thread != null) thread.quitSafely(); + } + + /** + * Moves whatever can move. Always called holding [lock]. + * + *

    Both queues are peeked and only dropped once the frame is actually in the codec's hands, + * so a buffer index cannot be lost on the way past a failure -- the decoder is given a fixed + * number of them and a leaked one never comes back.

    + */ + private void pump() { + while (!released && !pending.isEmpty() && !freeInputs.isEmpty()) { + var index = freeInputs.peek(); + var frame = pending.peek(); + if (index == null || frame == null) return; + try { + var buffer = codec.getInputBuffer(index); + if (buffer == null) return; + buffer.clear(); + buffer.put(frame); + codec.queueInputBuffer(index, 0, frame.length, nextPtsUs, 0); + nextPtsUs += PTS_STEP_US; + freeInputs.poll(); + pending.poll(); + lock.notifyAll(); + } catch (Exception e) { + fail(e); + return; + } + } + } + + private void fail(@NonNull Exception cause) { + synchronized (lock) { + if (failed || released) return; + failed = true; + lock.notifyAll(); + } + Log.w(TAG, "H.264 decode failed", cause); + listener.onDecoderFailed(cause); + } + + private final class Callback extends MediaCodec.Callback { + @Override + public void onInputBufferAvailable(@NonNull MediaCodec codec, int index) { + synchronized (lock) { + if (released) return; + freeInputs.add(index); + pump(); + } + } + + @Override + public void onOutputBufferAvailable(@NonNull MediaCodec codec, int index, + @NonNull MediaCodec.BufferInfo info) { + try { + // true: hand it to the Surface now. There is no presentation schedule to keep -- + // the frame describes the guest's screen as of when it was encoded, so the only + // right time to show it is immediately. + codec.releaseOutputBuffer(index, true); + } catch (Exception e) { + fail(e); + } + } + + @Override + public void onError(@NonNull MediaCodec codec, @NonNull MediaCodec.CodecException e) { + fail(e); + } + + @Override + public void onOutputFormatChanged(@NonNull MediaCodec codec, @NonNull MediaFormat format) { + // The guest resized, or the decoder settled on a size of its own. Nothing to do: the + // Surface is scaled by the view, and the RFB side is what notices a resize and restarts + // this channel so the header is read again. + Log.i(TAG, fmt("decoder output format now %s", format)); + } + } + + /** Whether this decoder is still usable. */ + public boolean isAlive() { + synchronized (lock) { + return !released && !failed; + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264ConsolePipeline.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264ConsolePipeline.java new file mode 100644 index 00000000..b1c302b5 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264ConsolePipeline.java @@ -0,0 +1,403 @@ +// 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.ui.vm.display.vnc.h264; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import android.graphics.SurfaceTexture; +import android.os.Handler; +import android.util.Log; +import android.view.Surface; +import android.view.TextureView; + +import androidx.annotation.AnyThread; +import androidx.annotation.MainThread; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +/** + * The console's H.264 half: encoding-50 rects in, a picture on a {@link TextureView} out. + * + *

    There is no socket here any more. The frames arrive on the ordinary RFB connection as rects, + * so what this owns is the two pieces that still have to agree with each other -- the codec and the + * view it draws into -- under the same rule as before: this only ever runs on top of a live RFB + * connection. The RFB client stays up whatever happens here, input keeps riding it, and the + * failure of anything in this file is a view being hidden again rather than a console that stops + * working.

    + * + *

    A {@link TextureView} rather than a {@link android.view.SurfaceView}: the decoder's picture has + * to land exactly where the RFB canvas was, and the canvas is positioned by a layout pass plus a + * scale and a translation that the viewport controller recomputes on every chrome, IME and rotation + * change. A TextureView is an ordinary view in that pass, so "exactly where the canvas is" is the + * same three property assignments applied twice. A SurfaceView is composited outside it, and would + * have made the alignment a second, separately-wrong geometry calculation. It is not clickable and + * not focusable, so touches fall through it to the canvas underneath and every input mode keeps + * working while it is up.

    + * + *

    Every callback names the generation it came from. A decoder's failure, or its surface + * going away, arrives on the main thread some time after the object it describes was replaced -- + * which is not a rare interleaving but the ordinary one, because a guest resize replaces the + * decoder while frames for both sizes are in flight. Without the name, the dying generation's + * farewell tore down the one that had replaced it.

    + * + *

    A geometry is a generation, and a generation is a decoder. MediaCodec is configured for + * one size, so a rect at a new one cannot be fed to the codec that was standing; the rect carries + * the coded size for exactly this reason, and the server sets the reset flags on the first rect at + * a new geometry so that the bytes behind it are a sync frame rather than a continuation.

    + */ +public final class H264ConsolePipeline { + private static final String TAG = "H264ConsolePipeline"; + /** + * How long a frame waits for the main thread to answer a new geometry with a decoder. + * + *

    Normally microseconds -- the console has been on screen showing RFB, so the view is + * already available and the answer is one main-thread post away. The wait exists so that the + * first rect of a stream, which is the sync frame the whole stream is built on, is not the one + * frame that gets dropped. It is bounded because a wedged main thread must not park the RFB + * message loop, which is also what carries the keyboard.

    + */ + private static final long CONFIGURE_WAIT_MS = 1500; + + /** This device has no {@code video/avc} decoder, so no connection here can ever show one. */ + public static final class NoDecoderException extends IOException { + NoDecoderException() { + super("no H.264 decoder on this device"); + } + } + + /** Called on the main thread. */ + public interface Listener { + /** The decoder is rendering. The RFB canvas underneath is now redundant. */ + void onStreamLive(int width, int height); + + /** + * The pipeline is down and the RFB canvas is what shows the screen again. + * + * @param wasLive whether it had ever been on screen. + * @param cause what ended it, or null when nothing went wrong -- a deliberate close, or + * the window going away underneath it, which is what backgrounding the + * console looks like and is not a fault to report. + */ + void onStreamGone(boolean wasLive, @Nullable Exception cause); + } + + /** + * One decoder, for one coded geometry, and the latch that says whether it exists yet. + * + *

    The latch is settled rather than signalled: it counts down once the main thread has + * decided, whether the decision was a decoder or "there is nowhere to draw". A frame arriving + * while there is nowhere to draw has to be dropped immediately rather than waited on, because + * the alternative is the message loop stalling for a second and a half per frame for as long as + * the console is in the background.

    + */ + private static final class Generation { + final int width; + final int height; + final CountDownLatch settled = new CountDownLatch(1); + @Nullable + volatile H264ConsoleDecoder decoder; + + /** Whether this generation's decoder has been fed yet. Message-loop thread only. */ + boolean decoderFed; + + Generation(int width, int height) { + this.width = width; + this.height = height; + } + } + + private final TextureView view; + private final Handler main; + private final Listener listener; + /** + * Where the rect a decoder can start on is kept. + * + *

    Not owned here, and deliberately not: the sync frame has to outlive this object. The + * server sends the parameter sets exactly once per client, on the reset-flagged rect that + * starts its stream, and a pipeline is built and thrown away several times over the life of one + * connection -- the presentation console builds its first one only when a display has been + * chosen, and another every time that window is rebuilt. A cache scoped to the pipeline would + * be empty in exactly the case it exists for. See {@link H264SyncFrameCache}.

    + */ + private final H264SyncFrameCache syncFrames; + + /** Written on the main thread; read by the message loop to find out what it may feed. */ + @Nullable + private volatile Generation generation; + /** Set once this console has decided it will not decode again; see {@link #disable}. */ + private volatile boolean disabled; + @Nullable + private Surface surface; + /** + * The decoder currently attached to the surface, whatever generation stood it up. Tracked + * beside the generation because a new generation replaces the surface and the decoder together + * -- a guest resize builds a second decoder while the first is still attached -- and the one + * being replaced has to be released without a reference to the generation that owns it. + */ + @Nullable + private H264ConsoleDecoder attachedDecoder; + private boolean live; + private boolean stopping; + + public H264ConsolePipeline(@NonNull TextureView view, @NonNull Handler main, + @NonNull Listener listener, + @NonNull H264SyncFrameCache syncFrames) { + this.view = view; + this.main = main; + this.listener = listener; + this.syncFrames = syncFrames; + } + + public boolean isLive() { + return live; + } + + /** + * Feeds one encoding-50 rect body, header and all. + * + *

    Called on the RFB message-loop thread, and the blocking inside is the point: a stalled + * decoder stops this thread, this thread stops draining the socket, and the server -- which + * only sends what an outstanding request asked for -- stops being asked. That chain is what + * keeps a slow device showing late frames rather than wrong ones.

    + * + *

    Nothing thrown, ever. A body that will not parse is a disagreement between the reader that + * pulled it off the socket and the parser here, not a desynchronised socket -- the reader took + * exactly the bytes the length declared -- so the connection is fine and only this pipeline has + * to come down.

    + */ + @AnyThread + public void submitStreamRect(@NonNull byte[] rectBody, int width, int height) { + if (disabled) return; + H264RectProtocol.StreamRect rect; + try { + rect = H264RectProtocol.parseStreamRect(rectBody); + if (width <= 0 || height <= 0) + throw new IOException(fmt("h264 rect at %dx%d has no picture in it", width, height)); + } catch (IOException e) { + var doomed = generation; + main.post(() -> teardown(doomed, e)); + return; + } + var gen = generation; + if (gen == null || gen.width != width || gen.height != height) { + var fresh = new Generation(width, height); + // Published before the post, so that a rect arriving on the heels of this one finds the + // generation it is about to be told to wait for rather than making a second one. + generation = fresh; + gen = fresh; + main.post(() -> configure(fresh)); + } + // Cached before the latch, so that a reset-flagged rect dropped now -- because the surface + // is not ready this instant -- is still on hand to prime the decoder that stands up a frame + // later. The reset flag is exactly what marks the rect that carries the parameter sets. + // decoderFed is not disturbed: it is per-generation, and a mid-stream reset of a decoder + // that has already been fed must go through the reset path in feed(), not the prime path. + if (rect.resetsDecoder()) syncFrames.remember(width, height, rect.annexB); + try { + if (!gen.settled.await(CONFIGURE_WAIT_MS, TimeUnit.MILLISECONDS)) { + Log.w(TAG, "the main thread did not answer a new stream geometry in time"); + return; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + var decoder = gen.decoder; + // Null is the ordinary "there is no window for this picture" -- a backgrounded console, or + // one whose presentation window has not been built yet. The frame is dropped rather than + // held, but the sync frame it may have carried is not: the cache kept it, and the next + // frame to reach a live decoder replays it first. + if (decoder == null || generation != gen) return; + try { + feed(gen, decoder, rect); + } catch (IOException e) { + var doomed = gen; + main.post(() -> teardown(doomed, e)); + } + } + + /** + * Hands one rect to the decoder, priming it with the cached sync frame the first time. + * + *

    The priming is what closes the black-screen hole. When a decoder is fed for the first + * time it may be one that stood up after the sync rect went by -- so if this rect is not itself + * a sync (it does not reset), the cached sync frame for this geometry goes in ahead of it, and + * the decoder gets its SPS/PPS before the delta that would otherwise mean nothing. A rect that + * is a sync needs none of this: it carries its own parameter sets, and a decoder this fresh has + * no prior context to reset.

    + * + *

    The cache is read by geometry rather than per generation because the decoder standing up + * here may be the first one this connection ever had -- the sync rect can predate the pipeline + * itself, not merely this generation of it.

    + * + *

    All on the message-loop thread, so {@code decoderFed} and the cache need no guarding: the + * one thread that reads them is the one that writes them.

    + */ + private void feed(@NonNull Generation gen, @NonNull H264ConsoleDecoder decoder, + @NonNull H264RectProtocol.StreamRect rect) throws IOException { + if (!gen.decoderFed) { + gen.decoderFed = true; + if (rect.resetsDecoder()) { + decoder.submit(rect.annexB); + return; + } + var sync = syncFrames.forGeometry(gen.width, gen.height); + if (sync != null) decoder.submit(sync); + decoder.submit(rect.annexB); + return; + } + if (rect.resetsDecoder()) decoder.reset(); + decoder.submit(rect.annexB); + } + + /** Takes the pipeline down deliberately: no reason, no notice beyond the state change. */ + @MainThread + public void stop() { + teardown(generation, null); + } + + /** + * Takes it down and keeps it down, for a console that has decided it is not going to decode. + * + *

    Needed because the frames keep arriving whatever this object thinks: the stream rides the + * RFB connection now, so there is no socket to close to make it stop. Only the server can stop + * sending, and the only thing that makes it stop is a connection that never asked.

    + */ + @MainThread + public void disable() { + disabled = true; + stop(); + } + + /** + * Stands a decoder up for [gen]'s geometry, or arranges to hear about it when one can exist. + * + *

    The surface listener goes on before the availability check and stays on for the whole + * generation, because the case it exists for is not only "the surface does not exist yet" but + * also "the surface stopped existing" -- and the second one arrives on a view that was + * available when this ran.

    + */ + @MainThread + private void configure(@NonNull Generation gen) { + if (generation != gen || stopping) { + gen.settled.countDown(); + return; + } + // Releases the decoder and surface of the generation this one replaces -- a guest resize + // reaches here with the previous decoder still attached, and without this it would run on + // against a surface about to be released and never be freed. + releaseDecoderAndSurface(); + view.setSurfaceTextureListener(new SurfaceListener(gen)); + view.setVisibility(VISIBLE); + var texture = view.getSurfaceTexture(); + if (view.isAvailable() && texture != null) attach(gen, texture); + else gen.settled.countDown(); + } + + @MainThread + private void attach(@NonNull Generation gen, @NonNull SurfaceTexture texture) { + if (generation != gen || stopping || gen.decoder != null) return; + // The decoder writes frames at the coded size the rect announced; the view scales whatever + // it is given, so the two never have to be made equal. + texture.setDefaultBufferSize(gen.width, gen.height); + var target = new Surface(texture); + surface = target; + var started = new H264ConsoleDecoder(cause -> main.post(() -> teardown(gen, cause))); + if (!started.start(target, gen.width, gen.height)) { + gen.settled.countDown(); + teardown(gen, new NoDecoderException()); + return; + } + gen.decoder = started; + attachedDecoder = started; + live = true; + // Counted down only now: a frame waiting on this must find the decoder published, not the + // latch open and the field still empty. The message loop primes it with the generation's + // cached sync frame on the first submit, so a decoder that stood up after the sync rect + // still gets its parameter sets. + gen.settled.countDown(); + Log.i(TAG, fmt("decoding the console at %dx%d", gen.width, gen.height)); + listener.onStreamLive(gen.width, gen.height); + } + + /** + * Puts everything back the way it was, once. Idempotent because it is reached from five places + * -- the console closing, a rect that would not parse, the decoder failing, a submit that timed + * out and the surface going away -- and two of them can happen at the same moment. + * + *

    [gen] is the generation the caller believes it is ending. A teardown for one that is no + * longer current is a message from a decoder that has already been replaced, and doing its + * bidding would tear down the decoder that replaced it.

    + */ + @MainThread + private void teardown(@Nullable Generation gen, @Nullable Exception cause) { + if (stopping || generation == null || generation != gen) return; + stopping = true; + var wasLive = live; + live = false; + if (cause != null) Log.w(TAG, "the console's H.264 stream is down", cause); + generation = null; + // Anything parked on this generation is waiting for a decoder that is not coming. + gen.settled.countDown(); + gen.decoder = null; + releaseDecoderAndSurface(); + view.setSurfaceTextureListener(null); + view.setVisibility(GONE); + stopping = false; + listener.onStreamGone(wasLive, cause); + } + + /** Releases whatever decoder is attached to the surface, and the surface, if any. */ + @MainThread + private void releaseDecoderAndSurface() { + if (attachedDecoder != null) { + attachedDecoder.release(); + attachedDecoder = null; + } + if (surface != null) { + surface.release(); + surface = null; + } + } + + /** The decoder's window, watched for the whole life of the generation that asked for it. */ + private final class SurfaceListener implements TextureView.SurfaceTextureListener { + private final Generation gen; + + SurfaceListener(@NonNull Generation gen) { + this.gen = gen; + } + + @Override + public void onSurfaceTextureAvailable(@NonNull SurfaceTexture st, int w, int h) { + attach(gen, st); + } + + @Override + public void onSurfaceTextureSizeChanged(@NonNull SurfaceTexture st, int w, int h) { + // The view moved or resized. The decoder writes at the stream's coded size and the view + // scales it, so there is nothing to reconfigure. + } + + @Override + public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture st) { + // The window went away underneath the decoder, which is what backgrounding the console + // looks like. Reported without a cause because nothing is wrong: the rects keep + // arriving on a connection that is still up, and the generation they build when the + // window comes back is what puts the picture on screen again. + teardown(gen, null); + return true; + } + + @Override + public void onSurfaceTextureUpdated(@NonNull SurfaceTexture st) { + } + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264ProbePolicy.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264ProbePolicy.java new file mode 100644 index 00000000..4deec9cb --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264ProbePolicy.java @@ -0,0 +1,224 @@ +// 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.ui.vm.display.vnc.h264; + +import androidx.annotation.NonNull; + +/** + * Whether this console is decoding, waiting for something to decode, or on the pixel path -- and + * what has to happen to move it between the three. + * + *

    The rules are {@code plans/H264_SINGLE_PORT.md} section 1's client half, and they are here + * rather than in the activity so that they can be read off a test instead of a stopwatch: this + * class has no Android in it and no clock of its own. Every method takes the current time; the + * activity's only job is to say what time it is and to do what {@link #tick} returns.

    + * + *

    Three things end the waiting, and only one of them is a message. A capabilities rect + * saying {@link H264RectProtocol#CAPS_NO_ENCODER} is a fact about the host, and the host does not + * grow an encoder while the VM runs, so it ends the question for good. Silence is the other two. + * Five seconds with no capabilities rect at all means the server is not one that knows this + * pseudo-encoding -- an old crosvm, or a still-running VM started before the change -- and the + * console simply stays on pixels, which is what that server is already serving it. Ten seconds + * with neither a frame nor a heartbeat, while decoding, means the stream is dead however alive the + * connection looks, because the heartbeat exists precisely so that a still screen and a dead host + * stop looking alike.

    + * + *

    The silence verdict is revocable and the refusal is not. Guessing from silence is a + * guess: a capabilities rect, a frame or a heartbeat arriving afterwards is direct evidence and + * takes the console back off the pixel path. A host that answered "no encoder" said so, and + * nothing short of a new connection reopens it. Keeping those two apart is also what keeps the + * status line honest -- an ordinary VNC screen must not tell every user that H.264 is unavailable + * merely because nobody ever offered it one.

    + */ +public final class H264ProbePolicy { + /** + * How long after connecting a capabilities rect may take before its absence is the answer. + * + *

    The server sends it as the first answer to the first request, so anything that is going to + * arrive arrives in one round trip on a loopback socket. The rest of this is slack for a VM + * whose first framebuffer is still being composed.

    + */ + public static final long CAPS_GRACE_MS = 5_000; + /** + * How long a decoding console may hear nothing before the stream is declared dead. + * + *

    The host beats every three seconds it has nothing else to send, so this is three intervals + * plus change: long enough that a late beat under load is not a funeral, short enough that a + * frozen console is measured in seconds.

    + */ + public static final long SILENCE_MS = 10_000; + /** + * How many times a dead stream is answered by reconnecting before it is answered by giving up. + * + *

    One. A reconnect fixes the case this is for -- a connection or a broker that wedged with + * the picture frozen on it -- and a second dead stream says the fault is not the sort that a + * reconnect fixes. Retrying past that is a console that spends its life reconnecting instead of + * showing the screen the pixel path would have shown it all along.

    + */ + public static final int DEAD_STREAM_RECONNECTS = 1; + + /** What the console should be showing, and therefore what its views should be doing. */ + public enum Mode { + /** Pixels for now; a stream may yet arrive. Nothing to tell the user. */ + WAITING, + /** The decoder is what paints this console. */ + DECODING, + /** Pixels, and this console is not expecting to leave them. */ + PIXELS + } + + /** What {@link #tick} asks the console to do, beyond whatever {@link #mode} now says. */ + public enum Order { + NOTHING, + /** The stream is dead. Drop the RFB session and open another; enrolment starts there. */ + RECONNECT + } + + private Mode mode = Mode.WAITING; + private boolean connected; + private long connectedAtMs; + /** When a frame or a heartbeat last arrived, and what the ten seconds are measured from. */ + private long lastSignalMs; + private boolean sawCaps; + /** The five-second verdict. A guess from silence, and so revocable by any later evidence. */ + private boolean assumedNoCaps; + /** The host answered {@link H264RectProtocol#CAPS_NO_ENCODER}. Never revoked. */ + private boolean saidNoEncoder; + /** This device could not stand up a decoder. Never revoked, and not about the host at all. */ + private boolean decoderUnsupported; + private int deadStreams; + private boolean deadStreamsExhausted; + + /** An RFB session came up. The five-second clock starts here. */ + public synchronized void onConnected(long nowMs) { + connected = true; + connectedAtMs = nowMs; + lastSignalMs = nowMs; + sawCaps = false; + assumedNoCaps = false; + // deadStreams deliberately survives: the reconnect that follows a dead stream is this + // policy's own doing, and a counter reset by it would ask for reconnects forever. + mode = isPermanent() ? Mode.PIXELS : Mode.WAITING; + } + + /** The RFB session ended. Nothing is decided until another one comes up. */ + public synchronized void onDisconnected() { + connected = false; + if (mode == Mode.DECODING) mode = Mode.WAITING; + } + + /** + * A capabilities rect arrived. [value] is the section 1 {@code value} byte, whatever it holds. + */ + public synchronized void onCapsRect(int value, long nowMs) { + sawCaps = true; + assumedNoCaps = false; + if (value == H264RectProtocol.CAPS_NO_ENCODER) { + saidNoEncoder = true; + mode = Mode.PIXELS; + return; + } + if (isPermanent()) return; + // CAPS_AVAILABLE, CAPS_WARMING, and anything this build cannot read. An unread value is + // treated as warming rather than as a refusal, for the reason every unknown token is: a + // newer host's new vocabulary must not permanently downgrade an old client. + if (value == H264RectProtocol.CAPS_AVAILABLE) enterDecoding(nowMs); + else mode = Mode.WAITING; + } + + /** A frame arrived. The strongest evidence there is, and it outranks any guess from silence. */ + public synchronized void onStreamRect(long nowMs) { + lastSignalMs = nowMs; + assumedNoCaps = false; + if (!isPermanent()) enterDecoding(nowMs); + } + + /** + * A heartbeat arrived. + * + *

    Liveness, and not a reason to start decoding: a heartbeat is exactly what a still screen + * looks like, and there is nothing in one to put on screen. It does revoke a guess made from + * silence, because a server that beats is a server that knew about the pseudo-encoding.

    + */ + public synchronized void onHeartbeat(long nowMs) { + lastSignalMs = nowMs; + assumedNoCaps = false; + } + + /** + * This device could not stand up an H.264 decoder at all. + * + *

    Not a fact about the host, and the only one of the permanent verdicts the console has to + * act on beyond changing what it shows: a client that has asked for encoding 50 is served no + * pixels, so one that cannot decode has to stop asking before it can have a picture again.

    + */ + public synchronized void onDecoderUnsupported() { + decoderUnsupported = true; + mode = Mode.PIXELS; + } + + /** + * Advances the two silence clocks. + * + * @return what the console has to do about it. The mode may have changed either way. + */ + @NonNull + public synchronized Order tick(long nowMs) { + if (!connected) return Order.NOTHING; + if (!sawCaps && !assumedNoCaps && !isPermanent() + && nowMs - connectedAtMs >= CAPS_GRACE_MS) { + assumedNoCaps = true; + mode = Mode.PIXELS; + } + if (mode == Mode.DECODING && nowMs - lastSignalMs >= SILENCE_MS) { + // Rearmed whichever branch is taken, so that a console that gave up does not re-report + // the same dead stream on every tick for the rest of its life. + lastSignalMs = nowMs; + deadStreams++; + if (deadStreams > DEAD_STREAM_RECONNECTS) { + deadStreamsExhausted = true; + mode = Mode.PIXELS; + return Order.NOTHING; + } + mode = Mode.WAITING; + return Order.RECONNECT; + } + return Order.NOTHING; + } + + @NonNull + public synchronized Mode mode() { + return mode; + } + + /** Whether nothing that can still happen on this console would put it back on the decoder. */ + public synchronized boolean isPermanent() { + return saidNoEncoder || decoderUnsupported || deadStreamsExhausted; + } + + /** + * Whether the host itself answered "no encoder". + * + *

    Told apart from every other way of ending up on pixels because it is the only one worth + * saying out loud: it is the case where the console can never do better and the user might + * otherwise wonder why. Silence is not this -- an ordinary VNC server has never claimed to + * offer a stream, and telling its user that H.264 is unavailable would be noise.

    + */ + public synchronized boolean saidNoEncoder() { + return saidNoEncoder; + } + + /** Whether this device turned out to have no decoder, which is what stops the advertisement. */ + public synchronized boolean isDecoderUnsupported() { + return decoderUnsupported; + } + + private void enterDecoding(long nowMs) { + // The ten seconds are measured from when frames started being expected, not from the last + // thing that happened to arrive. Without this a stream that warmed for a minute would be + // declared dead on the tick after it was finally announced. + if (mode != Mode.DECODING) lastSignalMs = nowMs; + mode = Mode.DECODING; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264RectProtocol.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264RectProtocol.java new file mode 100644 index 00000000..bdd66963 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264RectProtocol.java @@ -0,0 +1,178 @@ +// 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.ui.vm.display.vnc.h264; + +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; +import java.util.Arrays; + +/** + * The two rect bodies the H.264 console reads off the ordinary RFB connection, and the only part of + * the decoder path that can be tested without a device. + * + *

    Both layouts are pinned by {@code plans/H264_SINGLE_PORT.md} section 1 and are implemented + * here as written. Neither is negotiated, neither is derived from anything, and this class is the + * one place either is read -- the JNI layer hands up whole rect bodies rather than parsed ones + * precisely so that a unit test can feed this the same literal bytes the server-side test + * asserts.

    + * + *

    Encoding 50 ("Open H.264", rfbproto.rst): {@code u32} big-endian length, {@code u32} + * big-endian flags, then that many bytes of Annex-B NAL units. The flags say whether the decoder's + * context is to be thrown away first, which is how a resize arrives: the picture behind a + * reset-flagged rect is a sync frame at a geometry the previous one did not have.

    + * + *

    Encoding 0x44564831 ("DVH1"): four bytes, always, on a rect that is always 0x0 at 0,0 -- + * version, kind, value, reserved. It exists because RFB negotiation can say "I understand encoding + * 50" and cannot say either of the two things a client actually has to know: whether there is an + * encoder behind the server at all, and -- on a screen nobody is changing, where no frames is the + * correct amount of frames -- whether the connection is still alive. Bytes past the fourth and + * kinds this build does not know are ignored rather than refused, because the alternative is a + * newer host's new vocabulary dropping an old client's connection.

    + * + *

    Both parsers refuse rather than guess when the bytes and the lengths disagree. The length in + * an encoding-50 body is read twice -- once in C, to know how much to pull off the socket, and once + * here -- and the check that the two readings agreed is what keeps that duplication from being a + * place the seam can quietly come apart.

    + */ +public final class H264RectProtocol { + /** rfbproto.rst's "Open H.264" encoding number. */ + public static final int ENCODING_H264 = 50; + /** "DVH1" in ASCII: DroidVM's pseudo-encoding, in the unassigned vendor-style positive space. */ + public static final int ENCODING_DVH1 = 0x44564831; + + /** u32 BE length + u32 BE flags, ahead of the Annex-B payload. */ + public static final int RECT_HEADER_BYTES = 8; + /** Throw away the decoder context this rect's geometry names, then decode. */ + public static final int FLAG_RESET_CONTEXT = 0x1; + /** Throw away every decoder context, then decode. Never set together with the above. */ + public static final int FLAG_RESET_ALL_CONTEXTS = 0x2; + /** + * The largest payload this client will allocate for. An IDR of a desktop-sized screen is orders + * of magnitude below it; above it the only readings are a stream that has lost its place or a + * malicious one, and both are better answered by ending the connection than by allocating what + * the number asked for. + */ + public static final int MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; + + /** The whole of a DVH1 rect: version, kind, value, reserved. */ + public static final int DVH_PAYLOAD_BYTES = 4; + /** The only version this build reads. Anything else is ignored, not refused. */ + public static final int DVH_VERSION = 1; + /** {@code kind}: what the host can do. */ + public static final int KIND_CAPABILITIES = 0; + /** {@code kind}: the stream is quiet, not dead. */ + public static final int KIND_HEARTBEAT = 1; + /** {@code value} of a capabilities rect: an encoder is up, or is expected to come up. */ + public static final int CAPS_AVAILABLE = 0; + /** {@code value}: no encoder on this host. Permanent -- stop waiting for one. */ + public static final int CAPS_NO_ENCODER = 1; + /** {@code value}: asked for, not producing yet. Wait; another caps rect will say when. */ + public static final int CAPS_WARMING = 2; + + private H264RectProtocol() { + } + + /** One encoding-50 rect: the flags it carried and the coded bytes behind them. */ + public static final class StreamRect { + public final int flags; + @NonNull + public final byte[] annexB; + + StreamRect(int flags, @NonNull byte[] annexB) { + this.flags = flags; + this.annexB = annexB; + } + + /** + * Whether the decoder must be put back to nothing before these bytes go in. + * + *

    The two flags are separate on the wire because a viewer that keeps one decoder context + * per rectangle has two different things to throw away. This console has exactly one + * decoder, so they are the same instruction to it, and reading them as alternatives is what + * the server does too -- it never sets both.

    + */ + public boolean resetsDecoder() { + return (flags & (FLAG_RESET_CONTEXT | FLAG_RESET_ALL_CONTEXTS)) != 0; + } + } + + /** + * Parses one encoding-50 rect body, header included. + * + * @throws IOException when the body cannot be read as one -- too short for its own header, a + * length past the guard, or a length that disagrees with the bytes present. + * Every one of those means the stream and the parser no longer agree about + * where the next rect begins, which the caller ends the connection over. + */ + @NonNull + public static StreamRect parseStreamRect(@Nullable byte[] rect) throws IOException { + if (rect == null || rect.length < RECT_HEADER_BYTES) + throw new IOException(fmt("h264 rect of %d bytes has no room for its header", + rect == null ? 0 : rect.length)); + var length = u32(rect, 0); + var flags = u32(rect, 4); + if (length > MAX_PAYLOAD_BYTES) + throw new IOException(fmt( + "h264 rect declares %d bytes, past the %d-byte guard", length, MAX_PAYLOAD_BYTES)); + // The reader that pulled these bytes off the socket read the same length to know how many + // to ask for, so a disagreement here is the two readings having diverged rather than a + // short frame -- and a decoder fed the difference produces rubbish rather than an error. + var carried = rect.length - RECT_HEADER_BYTES; + if (length != carried) + throw new IOException(fmt( + "h264 rect declares %d bytes and carries %d", length, carried)); + return new StreamRect((int) flags, + Arrays.copyOfRange(rect, RECT_HEADER_BYTES, rect.length)); + } + + /** One DVH1 rect, as far as this build reads it. */ + public static final class DvhRect { + public final int version; + public final int kind; + public final int value; + + DvhRect(int version, int kind, int value) { + this.version = version; + this.kind = kind; + this.value = value; + } + + public boolean isCapabilities() { + return kind == KIND_CAPABILITIES; + } + + public boolean isHeartbeat() { + return kind == KIND_HEARTBEAT; + } + } + + /** + * Parses one DVH1 rect payload, or returns null for one this build cannot read. + * + *

    Null rather than an exception, and that is the whole point of the encoding: an unknown + * version is a newer host saying something, and a client that dropped the connection over it + * would turn a vocabulary gap into an outage. Bytes past the fourth are ignored for the same + * reason -- v1 has none, and a later version's extra ones must not make a v1 field unreadable. + * Short is different: fewer than four bytes is not a longer message, it is a broken one.

    + */ + @Nullable + public static DvhRect parseDvhRect(@Nullable byte[] payload) { + if (payload == null || payload.length < DVH_PAYLOAD_BYTES) return null; + var version = payload[0] & 0xFF; + if (version != DVH_VERSION) return null; + return new DvhRect(version, payload[1] & 0xFF, payload[2] & 0xFF); + } + + /** Big-endian, into a long, so that the high bit is a large number and not a negative one. */ + private static long u32(@NonNull byte[] buf, int at) { + return ((buf[at] & 0xFFL) << 24) + | ((buf[at + 1] & 0xFFL) << 16) + | ((buf[at + 2] & 0xFFL) << 8) + | (buf[at + 3] & 0xFFL); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264SyncFrameCache.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264SyncFrameCache.java new file mode 100644 index 00000000..0c3a2007 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/h264/H264SyncFrameCache.java @@ -0,0 +1,89 @@ +// 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.ui.vm.display.vnc.h264; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.io.IOException; + +/** + * The one rect a decoder can start on, kept for as long as the connection that sent it. + * + *

    The server sends the parameter sets exactly once per client: on the reset-flagged rect that + * starts its stream, {@code SPS PPS IDR} in one body. Every IDR after that is bare, and a decoder + * that never saw the first rect has nothing to decode any of them against -- it buffers forever + * and the screen stays black. Nothing on the wire brings that rect back: the connection is what + * enrolled the client, the connection is still fine, and the server has no reason to think anything + * was missed.

    + * + *

    So the rect has to outlive everything on this side that can come and go while the connection + * stays up. That is three things. The decoder goes with its surface when the console is + * backgrounded; the pipeline's generation goes with the decoder; and the pipeline itself does not + * exist until there is a view to draw into, which for the presentation console means until a + * display has been chosen -- a choice the connection does not wait for, so the rect that starts + * the stream can arrive before there is anything to hand it to. This is where it waits.

    + * + *

    One entry, keyed by geometry. A reset-flagged rect at a new size replaces it -- the parameter + * sets describe a coded size, so an entry at the old size is exactly the wrong thing to prime a + * decoder with -- and a decoder is only ever primed with an entry at its own size.

    + * + *

    Written on the RFB message-loop thread, which is the one thread rects arrive on, and read + * there. The connection ending clears it from that thread too. The field is volatile only so that + * a reconnect's fresh message loop, which is a different thread, starts from the cleared state.

    + */ +public final class H264SyncFrameCache { + private static final class Entry { + final int width; + final int height; + @NonNull + final byte[] annexB; + + Entry(int width, int height, @NonNull byte[] annexB) { + this.width = width; + this.height = height; + this.annexB = annexB; + } + } + + @Nullable + private volatile Entry entry; + + /** Keeps [annexB] as the sync frame for a stream at [width] x [height], replacing any other. */ + public void remember(int width, int height, @NonNull byte[] annexB) { + entry = new Entry(width, height, annexB); + } + + /** + * Reads one rect body the way the pipeline would, and keeps it if it is a sync frame. + * + *

    For a rect that arrives while there is no pipeline to hand it to. A body that will not + * parse is neither kept nor reported: the connection is fine -- the reader took exactly the + * bytes the length declared -- and there is no pipeline to bring down over it, so the honest + * thing to do with it is nothing. The pipeline that eventually exists will judge the next one + * for itself.

    + */ + public void rememberIfSync(@NonNull byte[] rectBody, int width, int height) { + if (width <= 0 || height <= 0) return; + H264RectProtocol.StreamRect rect; + try { + rect = H264RectProtocol.parseStreamRect(rectBody); + } catch (IOException e) { + return; + } + if (rect.resetsDecoder()) remember(width, height, rect.annexB); + } + + /** The sync frame for a stream at exactly [width] x [height], or null when none was kept. */ + @Nullable + public byte[] forGeometry(int width, int height) { + var e = entry; + return e != null && e.width == width && e.height == height ? e.annexB : null; + } + + /** Forgets it: the connection that sent it is over, and the next one is served its own. */ + public void clear() { + entry = null; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/input/VncExtraKeysPanel.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/input/VncExtraKeysPanel.java index 0f2c440d..f9dbbcf6 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/input/VncExtraKeysPanel.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/input/VncExtraKeysPanel.java @@ -1,6 +1,8 @@ +// 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.ui.vm.display.vnc.input; -import static android.view.KeyEvent.KEYCODE_CAPS_LOCK; import static cn.classfun.droidvm.ui.vm.display.base.X11Keymap.androidKeyToXKeysym; import androidx.annotation.NonNull; @@ -12,8 +14,8 @@ /** * Adapts the shared {@link DisplayExtraKeysPanel} to a VNC backend, emitting X keysyms through - * {@link VncClient}. The sticky-modifier handling lives in {@link BaseExtraKeysAdapter}; only the - * emit/ready hooks and the keysym-level send helpers are backend-specific. + * {@link VncClient}. The sticky-modifier and key down/up handling live in + * {@link BaseExtraKeysAdapter}; only the emit/ready hooks are backend-specific. */ public final class VncExtraKeysPanel extends BaseExtraKeysAdapter { @Nullable @@ -35,42 +37,12 @@ public DisplayExtraKeysPanel getPanel() { @Override protected void emitKey(int androidKeyCode, boolean down) { - if (vncClient != null) vncClient.sendKey(androidKeyToXKeysym(androidKeyCode), down); + int keysym = androidKeyToXKeysym(androidKeyCode); + if (keysym != 0 && vncClient != null) vncClient.sendKey(keysym, down); } @Override protected boolean isReady() { return vncClient != null && vncClient.isConnected(); } - - public void sendKeysym(int keysym) { - if (!isReady() || keysym == 0) return; - applyModifiers(true); - vncClient.sendKey(keysym, true); - vncClient.sendKey(keysym, false); - applyModifiers(false); - } - - public void sendKey(int androidKeyCode) { - sendKeysym(androidKeyToXKeysym(androidKeyCode)); - } - - public void sendChar(char ch) { - sendKeysym(ch); - } - - @Override - public void onKeyRepeat(int androidKeyCode) { - sendKey(androidKeyCode); - } - - @Override - public void onCharRepeat(char ch) { - sendChar(ch); - } - - @Override - public void onCapsToggle(boolean active) { - sendKey(KEYCODE_CAPS_LOCK); - } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/input/VncTouchPadPanel.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/input/VncTouchPadPanel.java index b9e99b1b..925170a2 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/input/VncTouchPadPanel.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/display/vnc/input/VncTouchPadPanel.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.ui.vm.display.vnc.input; import static java.lang.Math.max; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/VMEditActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/VMEditActivity.java index acb06d0f..c540d5d1 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/VMEditActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/VMEditActivity.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.ui.vm.edit; import static java.util.Objects.requireNonNull; @@ -27,11 +30,14 @@ import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.ui.BackAskHelper; +import cn.classfun.droidvm.lib.ui.CameraPermission; +import cn.classfun.droidvm.lib.ui.RecordAudioPermission; import cn.classfun.droidvm.lib.ui.SwipeableTabActivity; import cn.classfun.droidvm.lib.store.vm.VMConfig; import cn.classfun.droidvm.lib.store.vm.VMStore; import cn.classfun.droidvm.ui.vm.edit.base.VMEditBaseTab; import cn.classfun.droidvm.ui.vm.edit.base.VMEditTab; +import cn.classfun.droidvm.ui.vm.edit.peripheral.VMEditPeripheralTab; public final class VMEditActivity extends SwipeableTabActivity { private static final String TAG = "VMEditActivity"; @@ -47,6 +53,10 @@ public final class VMEditActivity extends SwipeableTabActivity { public Consumer currentPicker = null; public boolean editMode = false; public UUID editVMId = null; + private RecordAudioPermission recordAudioPermission; + private CameraPermission cameraPermission; + /** A microphone permission choice has already been offered during this edit session. */ + private boolean micPermissionHandled = false; private final Map sharedData = new HashMap<>(); @@ -59,6 +69,25 @@ public T get(@NonNull String key, T def) { return (T) sharedData.getOrDefault(key, def); } + /** Host mic permission gate, shared with the peripheral tab. */ + @NonNull + public RecordAudioPermission getRecordAudioPermission() { + return recordAudioPermission; + } + + /** Shares one microphone permission prompt between adding a device and saving the VM. */ + public void ensureRecordAudioThen(@NonNull Runnable action) { + micPermissionHandled = true; + recordAudioPermission.ensureThen(action); + } + + /** Host camera permission gate, shared with the peripheral tab. Unlike the mic one, a + * refusal here stops the peripheral being added -- see {@link CameraPermission}. */ + @NonNull + public CameraPermission getCameraPermission() { + return cameraPermission; + } + private void pickerResult(Uri uri) { if (currentPicker != null && uri != null) currentPicker.accept(uri); @@ -75,6 +104,10 @@ private void folderPickerResult(Uri uri) { protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_vm_edit); + // Registered here, ahead of the tabs: an ActivityResultLauncher can only be created + // before the activity reaches STARTED. + recordAudioPermission = new RecordAudioPermission(this); + cameraPermission = new CameraPermission(this); tabs = createTabInstances(); collapsingToolbar = findViewById(R.id.collapsing_toolbar); tabLayout = findViewById(R.id.tab_layout); @@ -159,7 +192,14 @@ private void initialize() { fab.setOnClickListener(v -> doSave()); setupTabs(); tabs.forEach(VMEditBaseTab::initValue); + // Both paths load a config. Without this a new VM showed whatever android:text the + // layout happened to carry, so every numeric field had two defaults -- the XML one the + // user actually got, and the optLong fallback that only applied when editing an older + // VM missing that key. They had already drifted: the DRM pool offered 1024 in the form + // while the code said 8, and nothing could notice, because each is only reachable from + // a path the other never takes. if (editMode) loadExistingConfig(); + else tabs.forEach(tab -> tab.loadConfig(VMConfig.createWithCustomizeDefaults(this))); } private void loadExistingConfig() { @@ -182,6 +222,13 @@ public List createTabInstances() { } private void doSave() { + var peripheralTab = getTab(VMEditTab.TAB_PERIPHERAL); + if (!micPermissionHandled + && peripheralTab instanceof VMEditPeripheralTab + && ((VMEditPeripheralTab) peripheralTab).hasMicrophone()) { + ensureRecordAudioThen(this::doSave); + return; + } // Commit any field still being edited: NIC MAC/offset/forward inputs // write back to the model on focus loss, so flush the focused view // before reading the tabs' config. @@ -220,7 +267,7 @@ private void doSave() { return; } } else { - config = new VMConfig(); + config = VMConfig.createWithCustomizeDefaults(this); } for (var tab : tabs) { try { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/base/VMEditBaseTab.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/base/VMEditBaseTab.java index a5fba6a9..0e972630 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/base/VMEditBaseTab.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/base/VMEditBaseTab.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.ui.vm.edit.base; import android.view.View; @@ -38,6 +41,12 @@ protected final boolean showValidateFailed(@NonNull CharSequence message) { return false; } + /** Says something the user needs to know now, without failing anything -- e.g. that the + * option they just turned on depends on one in another tab. */ + protected final void showHint(@NonNull CharSequence message) { + Snackbar.make(parent, view, message, Snackbar.LENGTH_LONG).show(); + } + public abstract void loadConfig(@NonNull VMConfig config); public abstract boolean validateInput(@NonNull VMStore store); diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/base/VMEditTab.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/base/VMEditTab.java index 93a93682..219b71ad 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/base/VMEditTab.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/base/VMEditTab.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.ui.vm.edit.base; import static java.util.Objects.requireNonNull; @@ -17,6 +20,7 @@ import cn.classfun.droidvm.ui.vm.edit.boot.VMEditBootTab; import cn.classfun.droidvm.ui.vm.edit.graphics.VMEditGraphicsTab; import cn.classfun.droidvm.ui.vm.edit.network.VMEditNetworkTab; +import cn.classfun.droidvm.ui.vm.edit.peripheral.VMEditPeripheralTab; import cn.classfun.droidvm.ui.vm.edit.storage.VMEditStorageTab; public enum VMEditTab implements StringEnum { @@ -44,6 +48,11 @@ public enum VMEditTab implements StringEnum { R.string.create_vm_tab_graphics, R.id.tab_content_graphics, VMEditGraphicsTab.class + ), + TAB_PERIPHERAL( + R.string.create_vm_tab_peripheral, + R.id.tab_content_peripheral, + VMEditPeripheralTab.class ); public static final VMEditTab DEFAULT = TAB_BASIC; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/basic/VMCpuAffinityDialog.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/basic/VMCpuAffinityDialog.java new file mode 100644 index 00000000..a9c73d2e --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/basic/VMCpuAffinityDialog.java @@ -0,0 +1,548 @@ +// 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.ui.vm.edit.basic; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; +import static cn.classfun.droidvm.lib.utils.StringUtils.getEditText; +import static cn.classfun.droidvm.lib.utils.StringUtils.joinNonEmpty; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.CheckBox; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; + +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputEditText; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import java.util.TreeSet; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.store.vm.CpuPlacementDraft; +import cn.classfun.droidvm.lib.store.vm.CpuPlacementPlan; +import cn.classfun.droidvm.lib.utils.CpuUtils; +import cn.classfun.droidvm.ui.widgets.row.SwitchRowWidget; +import cn.classfun.droidvm.ui.widgets.row.TextRowWidget; +import cn.classfun.droidvm.ui.widgets.tools.CpuCorePickerDialog; + +/** + * Editor for where a VM's vCPUs run, and for the derived-or-manual guest + * capacity/cluster values that describe that placement to the guest. + * + *

    Two modes over the same stored fields. Simple mode is one checkbox per + * host core: a checked core gets a vCPU of its own and the VM's CPU count is + * whatever the checked list adds up to -- which is the placement almost every + * VM wants and the only one whose CPU count cannot end up disagreeing with the + * bindings. Advanced mode is the per-vCPU editor: one row per vCPU, each + * picking any set of host cores, plus the topology section. + * + *

    The mode is not stored. Which one opens is read back off the stored + * placement ({@link CpuPlacementPlan#isOneToOne}): simple mode can only say + * "one vCPU per host core", so anything it cannot express -- a vCPU floating + * over several cores, an unbound vCPU, hand-written capacity/cluster values -- + * opens in advanced mode rather than being silently rewritten. Going the other + * way is lossy by construction, so it asks first. + * + *

    It hangs off the CPU count field's icon button rather than sitting inline + * in the tab, because the row list is a function of that count: opening the + * dialog reads the count once, so the rows can never disagree with it. (The + * inline version had to rebuild on focus-loss, which a user could sidestep.) + * + *

    Edits apply on OK only -- the working state is a copy, so Cancel is a real + * cancel. + */ +public final class VMCpuAffinityDialog { + /** Receives the accepted state; nothing is called on cancel. */ + public interface Callback { + /** + * @param draft the placement as edited. Its {@code vcpuCount} is the CPU + * count the field should now hold: simple mode derives it + * from the checked cores, every other path hands back the + * count the dialog was opened with. + */ + void onAccepted(@NonNull CpuPlacementDraft draft); + } + + private final Context context; + private final List hostCores; + /** Distinct host frequency tiers; 1 means little/big says nothing here. */ + private final int hostTiers; + /** Working copy; the caller's map is untouched until OK. */ + private final Map> affinity; + private final Callback callback; + + /** The count the CPU field held on open; restored when nothing is pinned. */ + private final int initialVcpuCount; + /** Working count: simple mode derives it from the checked host cores. */ + private int vcpuCount; + /** Checked host cores in simple mode, ascending; one vCPU each. */ + private final TreeSet simpleHosts = new TreeSet<>(); + private boolean advanced; + + private final SwitchRowWidget swAffinity; + private final View affinityOptions; + private final SwitchRowWidget swAdvanced; + private final View simpleSection; + private final TextRowWidget rowSimpleSummary; + private final LinearLayout coreRowsContainer; + private final View advancedSection; + private final LinearLayout rowsContainer; + private final SwitchRowWidget swAuto; + private final View autoPreview; + private final View manualInputs; + private final TextRowWidget rowCapacityPreview; + private final TextRowWidget rowClusterPreview; + private final TextInputEditText etCapacity; + private final TextInputEditText etClusters; + + private AlertDialog dialog; + private int builtVcpuCount = -1; + /** Set while code, not the user, moves a switch or checkbox. */ + private boolean updatingChecks; + + public VMCpuAffinityDialog( + @NonNull Context context, + @NonNull CpuPlacementDraft draft, + @NonNull Callback callback + ) { + this.context = context; + this.initialVcpuCount = draft.vcpuCount; + this.vcpuCount = this.initialVcpuCount; + this.hostCores = CpuUtils.getCores(); + this.hostTiers = CpuUtils.tierCount(this.hostCores); + this.affinity = CpuPlacementPlan.orderedCopy(draft.affinity); + this.callback = callback; + + var view = LayoutInflater.from(context) + .inflate(R.layout.dialog_vm_cpu_affinity, null); + swAffinity = view.findViewById(R.id.sw_cpu_affinity); + affinityOptions = view.findViewById(R.id.cpu_affinity_options); + swAdvanced = view.findViewById(R.id.sw_cpu_affinity_advanced); + simpleSection = view.findViewById(R.id.cpu_affinity_simple); + rowSimpleSummary = view.findViewById(R.id.row_cpu_affinity_simple_summary); + coreRowsContainer = view.findViewById(R.id.host_core_rows_container); + advancedSection = view.findViewById(R.id.cpu_affinity_advanced); + rowsContainer = view.findViewById(R.id.vcpu_rows_container); + swAuto = view.findViewById(R.id.sw_cpu_topology_auto); + autoPreview = view.findViewById(R.id.cpu_topology_auto_preview); + manualInputs = view.findViewById(R.id.cpu_topology_manual); + rowCapacityPreview = view.findViewById(R.id.row_cpu_capacity_preview); + rowClusterPreview = view.findViewById(R.id.row_cpu_cluster_preview); + etCapacity = view.findViewById(R.id.et_cpu_capacity); + etClusters = view.findViewById(R.id.et_cpu_clusters); + + // Drop bindings for vCPUs that no longer exist (count lowered since last edit). + this.affinity.keySet().removeIf(vcpu -> vcpu >= this.vcpuCount); + advanced = !simpleFits(draft.auto); + if (!advanced) + simpleHosts.addAll(CpuPlacementPlan.oneToOneHosts(this.affinity, this.vcpuCount)); + + swAffinity.setChecked(!this.affinity.isEmpty()); + swAdvanced.setChecked(advanced); + swAuto.setChecked(draft.auto); + etCapacity.setText(draft.manualCapacity); + etClusters.setText(draft.manualClusters); + buildCoreRows(); + + swAffinity.setOnCheckedChangeListener(() -> { + if (swAffinity.isChecked()) prefillIfEmpty(); + updateVisibility(); + }); + swAdvanced.setOnCheckedChangeListener(() -> { + if (updatingChecks) return; + if (swAdvanced.isChecked()) enterAdvanced(); + else leaveAdvanced(); + }); + swAuto.setOnCheckedChangeListener(() -> { + if (updatingChecks) return; + // Going manual with empty fields: seed them with what auto produced, so + // the user edits a working baseline instead of reconstructing it by hand. + if (!swAuto.isChecked()) seedManualIfEmpty(); + updateVisibility(); + }); + updateVisibility(); + + dialog = new MaterialAlertDialogBuilder(context) + .setTitle(R.string.create_vm_cpu_affinity_title) + .setView(view) + .setPositiveButton(android.R.string.ok, (d, w) -> accept()) + .setNegativeButton(android.R.string.cancel, null) + .create(); + dialog.show(); + updateOkEnabled(); + } + + private void accept() { + boolean on = swAffinity.isChecked(); + Map> result; + if (!on) result = new TreeMap<>(); + else if (advanced) result = affinity; + else result = CpuPlacementPlan.oneToOne(simpleHosts); + callback.onAccepted(new CpuPlacementDraft( + result, + on ? vcpuCount : initialVcpuCount, + // Simple mode hides the topology section, so it can only mean auto: + // reporting a stale manual override it never showed would be a lie. + !advanced || swAuto.isChecked(), + getEditText(etCapacity).trim(), + getEditText(etClusters).trim())); + } + + // --- mode --- + + /** + * Whether the working placement is one simple mode can express without + * losing anything: a 1:1 binding onto cores this device actually has, with + * the guest topology left to auto. Nothing pinned qualifies too -- with an + * empty affinity the capacity/cluster values are dropped anyway. + */ + private boolean simpleFits(boolean auto) { + if (affinity.isEmpty()) return true; + if (!auto) return false; + var hosts = CpuPlacementPlan.oneToOneHosts(affinity, vcpuCount); + if (hosts.isEmpty()) return false; + return CpuCorePickerDialog.hostCoreIndices(hostCores).containsAll(hosts); + } + + /** Simple -> advanced: the checked cores become the 1:1 map to edit. */ + private void enterAdvanced() { + advanced = true; + if (!simpleHosts.isEmpty()) { + affinity.clear(); + affinity.putAll(CpuPlacementPlan.oneToOne(simpleHosts)); + builtVcpuCount = -1; + } + updateVisibility(); + } + + /** + * Advanced -> simple. A map simple mode cannot express has to be flattened, + * which throws away bindings the user typed, so that path confirms first -- + * and says what will be left, since "one vCPU per core" can also change the + * VM's CPU count. + */ + private void leaveAdvanced() { + if (simpleFits(swAuto.isChecked())) { + applySimple(CpuPlacementPlan.oneToOneHosts(affinity, vcpuCount), false); + return; + } + var hosts = CpuPlacementPlan.flattenToOneToOne(affinity); + new MaterialAlertDialogBuilder(context) + .setTitle(R.string.create_vm_cpu_affinity_discard_title) + .setMessage(context.getString( + R.string.create_vm_cpu_affinity_discard_message, + hosts.size(), CpuUtils.compactRanges(joinCsv(hosts)))) + .setPositiveButton(android.R.string.ok, (d, w) -> applySimple(hosts, true)) + .setNegativeButton(android.R.string.cancel, (d, w) -> restoreAdvancedSwitch()) + .setOnCancelListener(d -> restoreAdvancedSwitch()) + .show(); + } + + private void applySimple(@NonNull List hosts, boolean dropTopology) { + advanced = false; + simpleHosts.clear(); + simpleHosts.addAll(hosts); + syncSimpleCount(); + if (dropTopology) { + // The confirmation said these are gone; leaving them in the fields + // would resurrect them on the next trip back to advanced mode. + updatingChecks = true; + swAuto.setChecked(true); + updatingChecks = false; + etCapacity.setText(""); + etClusters.setText(""); + } + updateVisibility(); + } + + private void restoreAdvancedSwitch() { + updatingChecks = true; + swAdvanced.setChecked(true); + updatingChecks = false; + } + + /** + * First enable with nothing bound. Either mode starts from the identity + * (vCPU i -> host i), the mapping whose derived capacity/cluster mirror the + * host exactly; simple mode checks as many cores as the CPU count asks for, + * so turning the switch on does not change the count by itself. + */ + private void prefillIfEmpty() { + if (advanced) { + if (affinity.isEmpty()) prefillIdentity(); + return; + } + if (!simpleHosts.isEmpty()) return; + int count = Math.min(vcpuCount, hostCores.size()); + for (int i = 0; i < count; i++) simpleHosts.add(hostCores.get(i).index); + syncSimpleCount(); + } + + /** In simple mode the CPU count is the checked list's length, nothing else. */ + private void syncSimpleCount() { + if (!simpleHosts.isEmpty()) vcpuCount = simpleHosts.size(); + } + + private void updateVisibility() { + boolean on = swAffinity.isChecked(); + affinityOptions.setVisibility(on ? VISIBLE : GONE); + simpleSection.setVisibility(advanced ? GONE : VISIBLE); + advancedSection.setVisibility(advanced ? VISIBLE : GONE); + boolean auto = swAuto.isChecked(); + autoPreview.setVisibility(auto ? VISIBLE : GONE); + manualInputs.setVisibility(auto ? GONE : VISIBLE); + if (on) { + if (advanced) rebuildRows(); + else refreshCoreRows(); + } + updateOkEnabled(); + } + + /** Simple mode with nothing checked asks for a zero-vCPU VM; refuse it. */ + private void updateOkEnabled() { + if (dialog == null) return; + var ok = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + if (ok == null) return; + ok.setEnabled(!(swAffinity.isChecked() && !advanced && simpleHosts.isEmpty())); + } + + // --- simple mode --- + + private void buildCoreRows() { + coreRowsContainer.removeAllViews(); + for (var core : hostCores) { + var box = new CheckBox(context); + box.setOnCheckedChangeListener((b, checked) -> { + if (updatingChecks) return; + if (checked) simpleHosts.add(core.index); + else simpleHosts.remove(core.index); + syncSimpleCount(); + // Unchecking a core renumbers every vCPU after it, so the whole + // list is relabelled rather than just the row that was tapped. + refreshCoreRows(); + updateOkEnabled(); + }); + coreRowsContainer.addView(box); + } + } + + /** + * Which vCPU a core ends up as is not written on the row: the mapping is + * simply the checked cores in order, and the label it would take does not + * fit next to the frequency and tier on a phone-width dialog. The count + * above the list carries the only part that is not implied by the checkbox. + */ + private void refreshCoreRows() { + for (int i = 0; i < hostCores.size(); i++) { + var box = (CheckBox) coreRowsContainer.getChildAt(i); + if (box == null) continue; + var core = hostCores.get(i); + boolean checked = simpleHosts.contains(core.index); + if (box.isChecked() != checked) { + updatingChecks = true; + box.setChecked(checked); + updatingChecks = false; + } + box.setText(CpuCorePickerDialog.label(context, core, hostTiers)); + } + rowSimpleSummary.setValue(String.valueOf(simpleHosts.size())); + // Only the state that blocks OK gets a subtitle; the rest speaks for itself. + rowSimpleSummary.setSubtitle(simpleHosts.isEmpty() + ? context.getString(R.string.create_vm_cpu_affinity_simple_empty) + : null); + } + + // --- advanced mode --- + + private void prefillIdentity() { + affinity.clear(); + int count = Math.min(vcpuCount, hostCores.size()); + for (int i = 0; i < count; i++) + affinity.put(i, new ArrayList<>(List.of(hostCores.get(i).index))); + } + + /** + * Rows are recreated only when the count changed; otherwise just their values + * refresh, so a picker result does not flicker the whole list. + */ + private void rebuildRows() { + if (vcpuCount != builtVcpuCount) { + builtVcpuCount = vcpuCount; + rowsContainer.removeAllViews(); + for (int i = 0; i < vcpuCount; i++) { + final int vcpu = i; + var row = new TextRowWidget(context); + row.setIcon(R.drawable.ic_cpu); + row.setText(context.getString( + R.string.create_vm_cpu_affinity_vcpu_fmt, vcpu)); + row.setOnClickListener(v -> showCorePicker(vcpu)); + rowsContainer.addView(row); + } + } + for (int i = 0; i < vcpuCount; i++) { + var row = (TextRowWidget) rowsContainer.getChildAt(i); + if (row == null) continue; + row.setValue(describeBinding(i)); + row.setSubtitle(describeHosts(i)); + } + refreshPreview(); + } + + /** {@code "CPU4-6"} for a bound vCPU, or the "not bound" label. */ + @NonNull + private String describeBinding(int vcpu) { + var hosts = affinity.get(vcpu); + if (hosts == null || hosts.isEmpty()) + return context.getString(R.string.create_vm_cpu_affinity_unbound); + // Ranges keep a wide selection inside the value column's one line, and + // match the form the stored flag uses anyway. + return fmt("CPU%s", CpuUtils.compactRanges(joinCsv(hosts))); + } + + /** + * What the binding means for the guest: the weakest core it can land on, since + * that is the one {@link CpuPlacementPlan#deriveCapacity} reports. Null (no + * subtitle) when unbound or when the host's tiers are unknown. + */ + @Nullable + private String describeHosts(int vcpu) { + var hosts = affinity.get(vcpu); + if (hosts == null || hosts.isEmpty()) return null; + var weakest = weakestCore(hosts); + if (weakest == null) return null; + var count = hosts.size() > 1 + ? context.getString(R.string.create_vm_cpu_affinity_cores_fmt, hosts.size()) + : ""; + var text = joinNonEmpty(CpuCorePickerDialog.LABEL_SEP, + count, + CpuUtils.formatFreq(weakest.maxFreqKHz), + CpuCorePickerDialog.tierLabel(context, weakest, hostTiers)); + // A single core on a host with no readable frequency has nothing to add; + // an empty subtitle would still take a line, so drop it entirely. + return text.isEmpty() ? null : text; + } + + /** + * The lowest-capacity host core among {@code hosts}, mirroring the minimum + * {@link CpuPlacementPlan#deriveCapacity} takes. Null when none of them exists + * on this host, which a config carried over from another device can do. + */ + @Nullable + private CpuUtils.CpuCore weakestCore(@NonNull List hosts) { + CpuUtils.CpuCore found = null; + CpuUtils.CpuCore weakest = null; + for (var core : hostCores) { + if (!hosts.contains(core.index)) continue; + if (found == null) found = core; + if (core.capacity <= 0) continue; + if (weakest == null || core.capacity < weakest.capacity) weakest = core; + } + return weakest != null ? weakest : found; + } + + private void showCorePicker(int vcpu) { + var hosts = affinity.get(vcpu); + CpuCorePickerDialog.show( + context, + context.getString(R.string.create_vm_cpu_affinity_vcpu_fmt, vcpu), + hosts == null ? "" : joinCsv(hosts), + picked -> { + // Empty selection means "no mask for this vCPU", which crosvm + // expresses by leaving it out of the map entirely. + if (picked.trim().isEmpty()) affinity.remove(vcpu); + else affinity.put(vcpu, CpuUtils.parseCpuSet(picked)); + rebuildRows(); + }); + } + + private void seedManualIfEmpty() { + var capacity = CpuPlacementPlan.deriveCapacity(affinity, hostCores); + if (getEditText(etCapacity).trim().isEmpty()) + etCapacity.setText(CpuPlacementPlan.formatCapacity(capacity)); + if (getEditText(etClusters).trim().isEmpty()) { + var clusters = CpuPlacementPlan.deriveClusters(capacity, vcpuCount); + if (clusters.size() > 1) + etClusters.setText(CpuPlacementPlan.formatClusters(clusters)); + } + } + + /** + * The derived values go in the subtitle, not the value column: the raw + * {@code 0=792,1=792,...} form outgrows the value column's one ellipsized line + * on any real core count, and the subtitle spans the row and wraps. The value + * column keeps only what stays short. + */ + private void refreshPreview() { + if (!swAuto.isChecked()) return; + var capacity = CpuPlacementPlan.deriveCapacity(affinity, hostCores); + var clusters = CpuPlacementPlan.deriveClusters(capacity, vcpuCount); + if (capacity.isEmpty()) { + // No host capacity readable, so neither flag can be emitted truthfully. + var unset = context.getString(R.string.create_vm_cpu_topology_unset); + rowCapacityPreview.setSubtitle(unset); + rowClusterPreview.setSubtitle(unset); + rowClusterPreview.setValue((CharSequence) null); + return; + } + rowCapacityPreview.setSubtitle(describeCapacity(capacity)); + // A lone cluster is crosvm's default, so nothing is emitted for it. + if (clusters.size() > 1) { + rowClusterPreview.setValue(context.getString( + R.string.create_vm_cpu_cluster_count_fmt, clusters.size())); + rowClusterPreview.setSubtitle(describeClusters(clusters)); + } else { + rowClusterPreview.setValue((CharSequence) null); + rowClusterPreview.setSubtitle( + context.getString(R.string.create_vm_cpu_cluster_single)); + } + } + + /** {@code "vCPU 0-5: 792 vCPU 6: 1024"} -- one group per distinct capacity. */ + @NonNull + private String describeCapacity(@NonNull Map capacity) { + var sb = new StringBuilder(); + for (var group : CpuPlacementPlan.groupByCapacity(capacity).entrySet()) { + if (sb.length() > 0) sb.append(CpuCorePickerDialog.LABEL_SEP); + sb.append(context.getString( + R.string.create_vm_cpu_capacity_group_fmt, + CpuUtils.compactRanges(joinCsv(group.getValue())), group.getKey())); + } + return sb.toString(); + } + + /** {@code "vCPU 0-5 vCPU 6"} -- the guest-visible cluster membership. */ + @NonNull + private String describeClusters(@NonNull List> clusters) { + var sb = new StringBuilder(); + for (var cluster : clusters) { + if (cluster.isEmpty()) continue; + if (sb.length() > 0) sb.append(CpuCorePickerDialog.LABEL_SEP); + sb.append(context.getString( + R.string.create_vm_cpu_affinity_vcpu_range_fmt, + CpuUtils.compactRanges(joinCsv(cluster)))); + } + return sb.toString(); + } + + @NonNull + private static String joinCsv(@NonNull Collection values) { + var sb = new StringBuilder(); + for (var value : values) { + if (sb.length() > 0) sb.append(','); + sb.append(value); + } + return sb.toString(); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/basic/VMEditBasicTab.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/basic/VMEditBasicTab.java index 31ce2dc2..6413b029 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/basic/VMEditBasicTab.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/basic/VMEditBasicTab.java @@ -1,23 +1,38 @@ +// 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.ui.vm.edit.basic; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static java.lang.Integer.parseInt; import static cn.classfun.droidvm.lib.utils.FileUtils.checkFileName; import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum; import static cn.classfun.droidvm.lib.store.vm.ProtectedVM.PROTECTED_WITHOUT_FIRMWARE; import static cn.classfun.droidvm.lib.utils.StringUtils.getEditText; +import android.content.Intent; import android.text.TextUtils; -import android.util.Log; import android.view.View; +import android.widget.TextView; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.android.material.button.MaterialButton; import com.google.android.material.textfield.TextInputEditText; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + 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.vm.CpuPlacementDraft; +import cn.classfun.droidvm.lib.store.vm.CpuPlacementPlan; import cn.classfun.droidvm.lib.store.vm.LendMthpMode; import cn.classfun.droidvm.lib.store.vm.ProtectedVM; import cn.classfun.droidvm.lib.store.vm.VMBackend; @@ -25,14 +40,18 @@ import cn.classfun.droidvm.lib.store.vm.VMConfig; import cn.classfun.droidvm.lib.store.vm.VMHypervisor; import cn.classfun.droidvm.lib.store.vm.VMStore; +import cn.classfun.droidvm.lib.utils.CpuUtils; import cn.classfun.droidvm.ui.vm.edit.VMEditActivity; +import cn.classfun.droidvm.ui.vm.notes.VMNotesActivity; import cn.classfun.droidvm.ui.vm.edit.base.VMEditBaseTab; import cn.classfun.droidvm.ui.widgets.row.ChooseRowWidget; import cn.classfun.droidvm.ui.widgets.row.SwitchRowWidget; import cn.classfun.droidvm.ui.widgets.row.TextInputRowWidget; +import cn.classfun.droidvm.ui.widgets.tools.CpuCorePickerDialog; public final class VMEditBasicTab extends VMEditBaseTab { - private final String TAG = "VMEditBasicTab"; + /** Matches the ti_max on input_cpu in partial_vm_edit_basic.xml. */ + private static final int MAX_VCPUS = 64; private TextInputRowWidget inputName; private TextInputRowWidget inputMemory; private TextInputRowWidget inputCpu; @@ -49,7 +68,27 @@ public final class VMEditBasicTab extends VMEditBaseTab { private ChooseRowWidget chooseProtectedVm; private ChooseRowWidget chooseBackend; private ChooseRowWidget chooseHypervisor; + private TextView tvNotesSummary; + private MaterialButton btnEditNotes; + private ActivityResultLauncher notesLauncher; + /** The Markdown the editor screen last left; the tab only carries it to saveConfig. */ + private String notes = ""; private TextInputEditText etExtraOptions; + private TextInputEditText etEnvironmentVariables; + + /** Host cores as reported by sysfs; read once, drives the cpuset picker. */ + private List hostCores = List.of(); + /** + * vCPU affinity held for the editor dialog and for save: vCPU index to host + * cores. Only vCPUs the user actually bound appear, matching crosvm's + * "absent means no mask". Edited through {@link VMCpuAffinityDialog}. + */ + private final Map> affinity = new TreeMap<>(); + /** Auto-derive capacity/cluster; the dialog owns this, the tab persists it. */ + private boolean cpuTopologyAuto = true; + /** Manual capacity/cluster overrides, only meaningful when auto is off. */ + private String manualCapacity = ""; + private String manualClusters = ""; public VMEditBasicTab(VMEditActivity parent, View view) { super(parent, view); @@ -58,6 +97,8 @@ public VMEditBasicTab(VMEditActivity parent, View view) { @Override public void initView() { inputName = view.findViewById(R.id.input_name); + tvNotesSummary = view.findViewById(R.id.tv_notes_summary); + btnEditNotes = view.findViewById(R.id.btn_edit_notes); inputMemory = view.findViewById(R.id.input_memory); inputCpu = view.findViewById(R.id.input_cpu); inputSwiotlb = view.findViewById(R.id.input_swiotlb); @@ -74,43 +115,74 @@ public void initView() { chooseBackend = view.findViewById(R.id.choose_backend); chooseHypervisor = view.findViewById(R.id.choose_hypervisor); etExtraOptions = view.findViewById(R.id.et_extra_options); + etEnvironmentVariables = view.findViewById(R.id.et_environment_variables); } @Override public void initValue() { + var act = new ActivityResultContracts.StartActivityForResult(); + notesLauncher = parent.registerForActivityResult(act, result -> { + var edited = VMNotesActivity.resultOf(result.getResultCode(), result.getData()); + if (edited == null) return; + notes = edited; + showNotesSummary(); + }); + btnEditNotes.setOnClickListener(v -> notesLauncher.launch( + VMNotesActivity.createIntent(parent, notes, inputName.getText()))); + showNotesSummary(); inputMemory.setValue(512, SizeUnit.MB); inputCpu.setValue(1); inputSwiotlb.setValue(64, SizeUnit.MB); + swBalloon.setChecked(false); + swPmu.setChecked(VMConfig.NEW_VM_DEFAULT_PMU); + swRng.setChecked(VMConfig.NEW_VM_DEFAULT_RNG); + swSmt.setChecked(VMConfig.NEW_VM_DEFAULT_SMT); + swUsb.setChecked(VMConfig.NEW_VM_DEFAULT_USB); + swSandbox.setChecked(false); + swHugepages.setChecked(VMConfig.NEW_VM_DEFAULT_HUGEPAGES); swDebug.setChecked(false); - chooseProtectedVm.configure(ProtectedVM.class, PROTECTED_WITHOUT_FIRMWARE); + chooseProtectedVm.configure( + ProtectedVM.class, VMConfig.NEW_VM_DEFAULT_PROTECTED_VM); chooseBackend.configure(VMBackend.class, VMBackend.DEFAULT); - chooseHypervisor.configure(VMHypervisor.class, VMHypervisor.DEFAULT); - choosePrepareLendMthp.configure(LendMthpMode.class, LendMthpMode.CHUNKED); + chooseHypervisor.configure( + VMHypervisor.class, VMHypervisor.defaultForNewVm(VMBackend.DEFAULT)); + choosePrepareLendMthp.configure( + LendMthpMode.class, LendMthpMode.defaultForDevice(parent)); parent.put("backend", VMBackend.DEFAULT); - parent.put("hypervisor", VMHypervisor.DEFAULT); + parent.put("hypervisor", chooseHypervisor.getSelectedItem()); chooseBackend.setOnValueChangedListener((oldValue, newValue) -> parent.put("backend", newValue)); chooseHypervisor.setOnValueChangedListener((oldValue, newValue) -> parent.put("hypervisor", newValue)); - try { - var socModel = QcomChipName.getCurrentSoC(); - var gunyah = new QcomGunyahSupports(parent); - if (gunyah.isCapacitySupported(socModel, "no_mthp")) - choosePrepareLendMthp.setSelectedItem(LendMthpMode.DISABLED); - if (gunyah.isCapacitySupported(socModel, "mthp_chunked")) - choosePrepareLendMthp.setSelectedItem(LendMthpMode.CHUNKED); - if (gunyah.isCapacitySupported(socModel, "mthp_single")) - choosePrepareLendMthp.setSelectedItem(LendMthpMode.SINGLE); - } catch (Exception e) { - Log.w(TAG, "failed to load soc capacity", e); + chooseProtectedVm.setOnValueChangedListener((oldValue, newValue) -> updateProtectedVisibility()); + updateProtectedVisibility(); + initCpuTopology(); + } + + /** + * The row under the Notes label: the first line that says something, so the row shows what is + * in there without pretending to be the editor. + */ + private void showNotesSummary() { + String summary = null; + for (var line : notes.split("\n")) { + var trimmed = line.trim(); + if (!trimmed.isEmpty()) { + summary = trimmed; + break; + } } + tvNotesSummary.setText(summary == null + ? parent.getString(R.string.vm_notes_empty) : summary); } @Override public void loadConfig(@NonNull VMConfig config) { var item = config.item; inputName.setText(config.getName()); + notes = config.getNotes(); + showNotesSummary(); inputMemory.setValue(item.optLong("memory_mb", 512), SizeUnit.MB); inputCpu.setValue(item.optLong("cpu_count", 1)); - inputSwiotlb.setValue(item.optLong("swiotlb_mb", 64), SizeUnit.MB); + inputSwiotlb.setValue(item.optLong("swiotlb_mb", 256), SizeUnit.MB); swBalloon.setChecked(item.optBoolean("balloon", false)); swPmu.setChecked(item.optBoolean("pmu", false)); swRng.setChecked(item.optBoolean("rng", false)); @@ -122,7 +194,11 @@ public void loadConfig(@NonNull VMConfig config) { choosePrepareLendMthp.setSelectedItem(LendMthpMode.fromItem(item)); chooseProtectedVm.setSelectedItem(optEnum(item, "protected_vm", PROTECTED_WITHOUT_FIRMWARE)); chooseBackend.setSelectedItem(optEnum(item, "backend", VMBackend.DEFAULT)); - chooseHypervisor.setSelectedItem(optEnum(item, "hypervisor", VMHypervisor.DEFAULT)); + var backend = optEnum(item, "backend", VMBackend.DEFAULT); + var configuredHypervisor = optEnum(item, "hypervisor", VMHypervisor.DEFAULT); + var hypervisor = VMHypervisor.resolveConfigured(backend, configuredHypervisor); + chooseHypervisor.setSelectedItem(hypervisor != null + ? hypervisor : VMHypervisor.defaultForNewVm(backend)); var extraOpts = item.opt("extra_options", null); if (extraOpts != null && extraOpts.is(DataItem.Type.ARRAY)) { var sb = new StringBuilder(); @@ -132,6 +208,123 @@ public void loadConfig(@NonNull VMConfig config) { } etExtraOptions.setText(sb.toString()); } + var environmentVariables = item.opt("environment_variables", null); + if (environmentVariables != null && environmentVariables.is(DataItem.Type.ARRAY)) { + var sb = new StringBuilder(); + for (int i = 0; i < environmentVariables.size(); i++) { + if (i > 0) sb.append('\n'); + sb.append(environmentVariables.optString(i, "")); + } + etEnvironmentVariables.setText(sb.toString()); + } else { + etEnvironmentVariables.setText(""); + } + loadCpuTopology(item); + updateProtectedVisibility(); + } + + private void loadCpuTopology(@NonNull DataItem item) { + affinity.clear(); + affinity.putAll(CpuPlacementPlan.parseAffinity( + item.optString(CpuPlacementPlan.KEY_AFFINITY, ""))); + cpuTopologyAuto = item.optBoolean(CpuPlacementPlan.KEY_AUTO, true); + manualCapacity = item.optString(CpuPlacementPlan.KEY_CAPACITY, ""); + manualClusters = item.optString(CpuPlacementPlan.KEY_CLUSTERS, ""); + } + + /** + * Wires up CPU placement: the vCPU affinity editor behind the CPU count field's + * icon button. The affinity, capacity and cluster flags are one decision from + * three sides -- affinity pins vCPU threads to host cores, capacity and cluster + * describe that placement to the guest via the FDT -- so they are edited together + * in {@link VMCpuAffinityDialog}. + */ + private void initCpuTopology() { + hostCores = CpuUtils.getCores(); + inputCpu.setIconButtonOnClickListener(this::showAffinityDialog); + } + + /** + * Opens the affinity editor for the vCPU count as currently entered. Reading + * the count here rather than tracking edits is the point of the dialog: the + * row list cannot disagree with the field. + * + *

    The count travels back the same way, because the dialog's simple mode + * derives it from the host cores that were checked -- one vCPU each. + */ + private void showAffinityDialog() { + var draft = new CpuPlacementDraft(affinity, currentVcpuCount(), + cpuTopologyAuto, manualCapacity, manualClusters); + new VMCpuAffinityDialog(parent, draft, accepted -> { + affinity.clear(); + affinity.putAll(accepted.affinity); + cpuTopologyAuto = accepted.auto; + manualCapacity = accepted.manualCapacity; + manualClusters = accepted.manualClusters; + if (accepted.vcpuCount != currentVcpuCount()) + inputCpu.setValue(accepted.vcpuCount); + }); + } + + // Gunyah dynamic memory sharing is a hypervisor-level memory-sharing mechanism (the GPU is + // just its first user), so it sits with the other lend/share options rather than in the + // graphics tab where it used to live. + /** + * Show the SWIOTLB size only where a VM can use one. + * + * A bounce pool exists because the hypervisor has taken the guest's memory away from the + * host: virtio has to hand the host buffers it can still reach. An unprotected VM never lost + * that access, and a pseudo-unprotected one gets it back before the payload runs, so in both + * the field would ask for memory nothing would ever bounce through -- and in the second it + * would actively hurt, putting a restricted-dma-pool node in the tree of a guest kernel that + * was never built to honour one. The backend ignores the stored value in those modes; this + * keeps the field from claiming otherwise. + */ + private void updateProtectedVisibility() { + var pvm = chooseProtectedVm.getSelectedItem(); + boolean bounces = pvm == ProtectedVM.PROTECTED_PROTECTED + || pvm == ProtectedVM.PROTECTED_WITHOUT_FIRMWARE; + inputSwiotlb.setVisibility(bounces ? VISIBLE : GONE); + } + + /** vCPU count as currently typed, clamped to the field's own 1..64 range. */ + private int currentVcpuCount() { + try { + var text = inputCpu.getText().trim(); + if (text.isEmpty()) return 1; + return Math.max(1, Math.min(parseInt(text), MAX_VCPUS)); + } catch (Exception ignored) { + return 1; + } + } + + @NonNull + private static String joinCsv(@NonNull Collection values) { + var sb = new StringBuilder(); + for (var value : values) { + if (sb.length() > 0) sb.append(','); + sb.append(value); + } + return sb.toString(); + } + + @SuppressWarnings("BooleanMethodIsAlwaysInverted") + private boolean checkInputField( + @NonNull TextInputEditText field, + boolean allowEmpty, int min, int max + ) { + field.setError(null); + try { + var text = getEditText(field); + if (text.isEmpty() && allowEmpty) return true; + var ret = parseInt(text); + if (ret < min || ret > max) + throw new IllegalArgumentException(); + return true; + } catch (Exception ignored) { + field.setError(parent.getString(R.string.create_vm_error_invalid_number)); + return false; + } } private boolean validateInputName(@NonNull VMStore store) { @@ -190,12 +383,81 @@ private boolean validateHypervisor(@NonNull VMStore ignored) { return true; } + private boolean validateEnvironmentVariables() { + etEnvironmentVariables.setError(null); + for (var line : getEditText(etEnvironmentVariables).split("\n")) { + var trimmed = line.trim(); + if (trimmed.isEmpty()) continue; + var separator = trimmed.indexOf('='); + if (separator <= 0 || trimmed.substring(0, separator).trim().isEmpty()) { + etEnvironmentVariables.setError( + parent.getString(R.string.create_vm_error_invalid_environment_variable)); + return false; + } + } + return true; + } + @Override public boolean validateInput(@NonNull VMStore store) { if (!validateInputName(store)) return false; if (!validateInputMemory(store)) return false; if (!validateInputCpu(store)) return false; if (!validateHypervisor(store)) return false; + if (!validateEnvironmentVariables()) return false; + if (!validateCpuTopology()) return false; + return true; + } + + /** + * The affinity dialog already keeps its own edits in range, so this mostly + * guards a stored config that was hand-edited, or a CPU count lowered after + * the affinity was set. + */ + private boolean validateCpuTopology() { + if (!affinity.isEmpty()) { + int count = currentVcpuCount(); + var hostIdx = CpuCorePickerDialog.hostCoreIndices(hostCores); + for (var entry : affinity.entrySet()) { + if (entry.getKey() >= count) + return showValidateFailed(R.string.create_vm_error_cpu_affinity_vcpu_oob); + for (var host : entry.getValue()) + if (!hostIdx.contains(host)) + return showValidateFailed(parent.getString( + R.string.create_vm_error_cpu_affinity_host_oob, host)); + } + if (!cpuTopologyAuto && !validateManualTopology(count)) return false; + } + return true; + } + + /** + * Hand-written capacity/cluster strings. Capacity is only range-checked; + * clusters additionally must not repeat a vCPU, which crosvm rejects + * ("CPU index must be unique"). + */ + private boolean validateManualTopology(int vcpuCount) { + var capacityText = manualCapacity.trim(); + if (!capacityText.isEmpty()) { + var capacity = CpuPlacementPlan.parseCapacity(capacityText); + if (capacity.isEmpty()) + return showValidateFailed(R.string.create_vm_error_cpu_capacity_invalid); + for (var entry : capacity.entrySet()) { + if (entry.getKey() >= vcpuCount) + return showValidateFailed(R.string.create_vm_error_cpu_affinity_vcpu_oob); + if (entry.getValue() > CpuUtils.MAX_CAPACITY) + return showValidateFailed(R.string.create_vm_error_cpu_capacity_invalid); + } + } + var clusters = CpuPlacementPlan.parseClusters(manualClusters.trim()); + var overlaps = CpuPlacementPlan.findClusterOverlaps(clusters); + if (!overlaps.isEmpty()) + return showValidateFailed(parent.getString( + R.string.create_vm_error_cpu_clusters_overlap, joinCsv(overlaps))); + for (var cluster : clusters) + for (var vcpu : cluster) + if (vcpu >= vcpuCount) + return showValidateFailed(R.string.create_vm_error_cpu_affinity_vcpu_oob); return true; } @@ -203,6 +465,7 @@ public boolean validateInput(@NonNull VMStore store) { public void saveConfig(@NonNull VMConfig config) { var item = config.item; config.setName(inputName.getText()); + config.setNotes(notes); item.set("memory_mb", inputMemory.getValue(SizeUnit.MB)); item.set("cpu_count", inputCpu.getValue()); item.set("swiotlb_mb", inputSwiotlb.getValue(SizeUnit.MB)); @@ -230,6 +493,34 @@ public void saveConfig(@NonNull VMConfig config) { arr.append(DataItem.newString(trimmed)); } item.set("extra_options", arr); + + var environmentVariables = DataItem.newArray(); + var environmentText = getEditText(etEnvironmentVariables); + for (var line : environmentText.split("\n")) { + var trimmed = line.trim(); + if (!trimmed.isEmpty()) + environmentVariables.append(DataItem.newString(trimmed)); + } + item.set("environment_variables", environmentVariables); + saveCpuTopology(item); + } + + private void saveCpuTopology(@NonNull DataItem item) { + // An empty affinity string is how CpuPlacementPlan is told to emit no CPU + // placement flags at all; the dialog returns an empty map when turned off. + item.set(CpuPlacementPlan.KEY_AFFINITY, CpuPlacementPlan.formatAffinity(affinity)); + item.set(CpuPlacementPlan.KEY_AUTO, cpuTopologyAuto); + item.set(CpuPlacementPlan.KEY_CAPACITY, manualCapacity); + item.set(CpuPlacementPlan.KEY_CLUSTERS, manualClusters); + } + + /** + * The vCPU affinity as currently edited, so the graphics tab can warn when the + * GPU worker cpuset overlaps it. Read-only view: the dialog owns the edits. + */ + @NonNull + public Map> getCurrentAffinity() { + return affinity; } /** diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/boot/VMEditBootTab.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/boot/VMEditBootTab.java index 0d3cbde5..85647d2f 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/boot/VMEditBootTab.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/boot/VMEditBootTab.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.ui.vm.edit.boot; import static android.view.View.GONE; @@ -398,6 +401,19 @@ private static String basename(@NonNull String path) { return path.substring(path.lastIndexOf('/') + 1); } + /** + * The storage tab moved a disk row from {@code from} to {@code to}: keep pointing at the + * same disk, whichever position it now has (rows between them shift by one). + */ + public void onDiskMoved(int from, int to) { + int idx = bootDiskIndex; + if (idx == from) idx = to; + else if (from < idx && idx <= to) idx--; + else if (to <= idx && idx < from) idx++; + bootDiskIndex = idx; + if (ddBootDisk != null) updateDiskDropdown(); + } + private void updateDiskDropdown() { var paths = diskPaths(); var labels = new ArrayList(); @@ -552,6 +568,10 @@ private void updateDetectionCard() { * just-changed selection is reflected, then the stored config, then * the backend default. In such a VM a guest kernel without * CONFIG_DMA_RESTRICTED_POOL cannot drive virtio. + * + *

    {@code PSEUDO_UNPROTECTED} is deliberately not in this list: its RAM is + * shared to the guest rather than lent, so there is no bounce pool and a + * stock kernel boots. Adding it here would warn about nothing. */ private boolean isProtectedVm() { ProtectedVM pvm = null; diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/graphics/ScreenBindingRow.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/graphics/ScreenBindingRow.java new file mode 100644 index 00000000..ad0a6509 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/graphics/ScreenBindingRow.java @@ -0,0 +1,726 @@ +// 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.ui.vm.edit.graphics; + +import static android.content.DialogInterface.BUTTON_POSITIVE; +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static java.lang.Integer.parseInt; +import static cn.classfun.droidvm.lib.utils.StringUtils.generateRandomPassword; +import static cn.classfun.droidvm.lib.utils.StringUtils.getEditText; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.WindowManager; +import android.widget.AutoCompleteTextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; + +import com.google.android.material.button.MaterialButton; +import com.google.android.material.dialog.MaterialAlertDialogBuilder; +import com.google.android.material.textfield.TextInputEditText; +import com.google.android.material.textfield.TextInputLayout; + +import java.util.ArrayList; +import java.util.List; + +import cn.classfun.droidvm.R; +import cn.classfun.droidvm.lib.ui.IconItemAdapter; +import cn.classfun.droidvm.lib.store.base.DataItem; +import cn.classfun.droidvm.lib.store.vm.DisplayExporter; +import cn.classfun.droidvm.lib.store.vm.DisplayTransportCap; +import cn.classfun.droidvm.lib.store.vm.VMScreenConfig; +import cn.classfun.droidvm.ui.widgets.row.ChooseRowWidget; +import cn.classfun.droidvm.ui.widgets.row.SwitchRowWidget; +import cn.classfun.droidvm.ui.widgets.row.TextRowWidget; + +/** + * One screen's rows in the graphics tab: the switch that says the VM has the device, how big that + * screen is and how often it produces a picture, and who exports it over what. + * + *

    The two screens have the same shape, so the wiring is written once here and the layout + * carries one block of ids per screen. The differences are both in "display settings" and both + * follow from what the device is: a virtio-gpu mode has a refresh rate and a DPI the guest is told + * about, and a framebuffer nobody announces frames for has a poll rate instead. Neither has the + * other's, so exactly one of the two is shown.

    + * + *

    The enable switch is passed in rather than found inside the block, because the renderer + * section sits between it and everything else on the virtio-gpu side, and an {@code } has + * nowhere to put a section in the middle of itself.

    + */ +final class ScreenBindingRow { + private static final int VNC_PASSWORD_LENGTH = 8; + /** The size bounds the editor saves within; the menu and its dialog both honour them. */ + private static final int MIN_EDGE = ScreenResolutionOptions.MIN_EDGE; + private static final int MAX_EDGE = 8192; + /** A mode's rate is bounded by what a panel could show; the poll rate by what crosvm takes. */ + private static final int MAX_REFRESH_RATE = 400; + /** The rates offered before "custom". Both screens take all three. */ + private static final int[] RATE_OPTIONS = {30, 60, 120}; + + /** Screen id, as stored and as handed to crosvm's {@code screen=}. */ + final String screenId; + /** True for {@code gpu-0}: it has a mode, so it has a refresh rate and a DPI. */ + private final boolean gpuScreen; + private final boolean defaultEnabled; + private final DisplayExporter defaultExporter; + + private final SwitchRowWidget swEnabled; + private final View options; + private final TextInputLayout tilResolution; + private final AutoCompleteTextView ddResolution; + private final TextInputLayout tilRate; + private final AutoCompleteTextView ddRate; + private final TextRowWidget rowWidthCpuFallback; + private final View dpiOptions; + private final TextInputEditText etDpiH; + private final TextInputEditText etDpiV; + private final ChooseRowWidget chooseExporter; + private final ChooseRowWidget chooseTransport; + private final SwitchRowWidget swInputEnabled; + private final View vncOptions; + private final TextInputLayout tilHost; + private final AutoCompleteTextView ddHost; + private final TextInputEditText etPort; + private final SwitchRowWidget swPasswordAuth; + private final View passwordOptions; + private final TextInputEditText etPassword; + + /** + * The geometry as picked, rather than as typed. + * + *

    It lives here because the two fields that show it are menus now: what a menu carries is a + * label, and the label is written from these numbers rather than parsed back out of. What + * reaches the config is unchanged -- the same three numbers under the same keys -- so a config + * written before this row was a menu still loads into it.

    + */ + private int width = (int) VMScreenConfig.DEFAULT_WIDTH; + private int height = (int) VMScreenConfig.DEFAULT_HEIGHT; + private int rate; + /** The sizes this device offers, settled once in {@link #init}. */ + @NonNull + private List sizes = List.of(); + /** + * The listen address as picked, rather than as typed -- the host field is a menu now, for the + * same reason the two above it are. + * + *

    May be "", which is not the same as {@link VncHostOptions#WILDCARD} even though it does + * the same thing: a config that names no host is one this app has never written, and rewriting + * it on a save the user made for some other reason is not this row's business. So the empty + * string survives a load and a save untouched, and only the menu's own label resolves it -- to + * the wildcard, because that is what both backends do with a host left unset.

    + */ + @NonNull + private String vncHost = VMScreenConfig.NEW_VM_DEFAULT_VNC_HOST; + /** The phone's own addresses, once the daemon has answered; see {@link #setScannedHosts}. */ + @NonNull + private List scannedHosts = List.of(); + /** The entries the host menu currently offers, in the order it offers them. */ + @NonNull + private List hostOptions = List.of(); + + /** + * @param block the root of this screen's {@code partial_vm_screen_binding} include, and also + * the view whose visibility follows the switch. Every lookup below is scoped to + * it, which is what keeps the two includes' identical ids apart -- an + * activity-wide findViewById would always find the first. + * @param switch_ this screen's enable switch, which lives in the parent block. + */ + ScreenBindingRow(@NonNull String screenId, @NonNull View block, + @NonNull SwitchRowWidget switch_, + boolean defaultEnabled, @NonNull DisplayExporter defaultExporter) { + this.screenId = screenId; + this.gpuScreen = VMScreenConfig.ID_GPU0.equals(screenId); + this.defaultEnabled = defaultEnabled; + this.defaultExporter = defaultExporter; + swEnabled = switch_; + options = block; + tilResolution = block.findViewById(R.id.til_screen_resolution); + ddResolution = block.findViewById(R.id.dd_screen_resolution); + tilRate = block.findViewById(R.id.til_screen_rate); + ddRate = block.findViewById(R.id.dd_screen_rate); + rowWidthCpuFallback = block.findViewById(R.id.row_screen_width_cpu_fallback); + dpiOptions = block.findViewById(R.id.screen_dpi_options); + etDpiH = block.findViewById(R.id.et_screen_dpi_h); + etDpiV = block.findViewById(R.id.et_screen_dpi_v); + chooseExporter = block.findViewById(R.id.choose_screen_exporter); + chooseTransport = block.findViewById(R.id.choose_screen_transport); + swInputEnabled = block.findViewById(R.id.sw_screen_input_enabled); + vncOptions = block.findViewById(R.id.screen_vnc_options); + tilHost = block.findViewById(R.id.til_screen_vnc_host); + ddHost = block.findViewById(R.id.dd_screen_vnc_host); + etPort = block.findViewById(R.id.et_screen_vnc_port); + swPasswordAuth = block.findViewById(R.id.sw_screen_vnc_password_auth); + passwordOptions = block.findViewById(R.id.screen_vnc_password_options); + etPassword = block.findViewById(R.id.et_screen_vnc_password); + MaterialButton btnClear = block.findViewById(R.id.btn_screen_vnc_password_clear); + MaterialButton btnGenerate = block.findViewById(R.id.btn_screen_vnc_password_generate); + btnClear.setOnClickListener(v -> etPassword.setText("")); + btnGenerate.setOnClickListener(v -> + etPassword.setText(generateRandomPassword(VNC_PASSWORD_LENGTH))); + // Which rate this screen has -- and whether it has a DPI at all -- is a property of the + // device, not of anything the user can change, so both are settled once here rather than + // in the visibility pass. One rate row either way; only its hint and its bounds differ. + rate = (int) (gpuScreen + ? VMScreenConfig.DEFAULT_REFRESH_RATE : VMScreenConfig.NEW_VM_DEFAULT_POLL_HZ); + tilRate.setHint(block.getContext().getString(rateHint())); + dpiOptions.setVisibility(gpuScreen ? VISIBLE : GONE); + } + + /** + * Wires the listeners. {@code onChanged} runs after every change that another row can depend + * on -- the tab re-runs its whole visibility pass there rather than each row guessing what + * else moved. + */ + void init(@NonNull Runnable onChanged) { + // Off is a real choice: a screen the VM has but nobody is watching is a state crosvm + // accepts, not a half-configured one. The default is what a brand-new VM comes up with, + // since only edit mode ever calls load(). + chooseExporter.configure(DisplayExporter.class, defaultExporter); + applyTransportOptions(defaultExporter, null); + swEnabled.setChecked(defaultEnabled); + // The absolute devices are what a screen has unless the user says otherwise, so a new VM + // and a config written before the key both come up with them on. + swInputEnabled.setChecked(true); + swEnabled.setOnCheckedChangeListener(onChanged); + // The width decides whether the GPU copy can take this screen's frames at all, so picking + // a size is one of the changes another row depends on -- the only geometry one that is. + // Through the tab's whole pass like every other change, rather than poking the one row it + // moves. + bindSizeMenu(onChanged); + bindRateMenu(onChanged); + bindHostMenu(); + chooseExporter.setOnValueChangedListener(() -> { + // The ladder belongs to the edge, so changing who is on the far end of it changes + // which rungs exist -- not just which are reachable. + applyTransportOptions(getExporter(), null); + onChanged.run(); + }); + swPasswordAuth.setOnCheckedChangeListener(onChanged); + } + + /** + * The size menu: every size this device has a reason to offer, smallest first, then "custom". + * + *

    Picking is the whole of the input path now, which is what turns the geometry check into a + * check on a stored value rather than on something half-typed: every entry above the last is + * in bounds by construction, and the last one validates before it hands anything back.

    + */ + private void bindSizeMenu(@NonNull Runnable onChanged) { + var ctx = ddResolution.getContext(); + var panel = panelSize(ctx); + sizes = ScreenResolutionOptions.build(panel[0], panel[1]); + var labels = new ArrayList(sizes.size() + 1); + for (var size : sizes) labels.add(sizeLabel(ctx, size.width, size.height)); + labels.add(ctx.getString(R.string.create_vm_display_custom)); + ddResolution.setAdapter(IconItemAdapter.create(ctx, labels, R.drawable.ic_monitor)); + ddResolution.setOnItemClickListener((parent, view, pos, id) -> { + if (pos < sizes.size()) { + var size = sizes.get(pos); + width = size.width; + height = size.height; + tilResolution.setError(null); + onChanged.run(); + } else { + askCustomSize(ctx, onChanged); + } + // The menu wrote the entry's own label into the field on its way out. Put the value + // back: after "custom" it would otherwise sit there reading "Custom..." while the + // dialog is still open, and after a pick the label is this row's to format. + applySizeText(); + }); + applySizeText(); + } + + /** The rate menu. Same shape as the size menu, and the same reason for it. */ + private void bindRateMenu(@NonNull Runnable onChanged) { + var ctx = ddRate.getContext(); + var labels = new ArrayList(RATE_OPTIONS.length + 1); + for (var hz : RATE_OPTIONS) labels.add(rateLabel(ctx, hz)); + labels.add(ctx.getString(R.string.create_vm_display_custom)); + ddRate.setAdapter(IconItemAdapter.create(ctx, labels, R.drawable.ic_speedometer)); + ddRate.setOnItemClickListener((parent, view, pos, id) -> { + if (pos < RATE_OPTIONS.length) { + rate = RATE_OPTIONS[pos]; + tilRate.setError(null); + onChanged.run(); + } else { + askCustomRate(ctx, onChanged); + } + applyRateText(); + }); + applyRateText(); + } + + /** + * The host menu: the two fixed addresses, the phone's own once they are known, then "custom". + * + *

    Rebuilt rather than filtered when the scan lands, because the scan is what most of the + * list is. Nothing here calls {@code onChanged}: no other row depends on which address this + * screen listens on -- unlike the port, which the tab checks pairwise across screens.

    + */ + private void bindHostMenu() { + var ctx = ddHost.getContext(); + hostOptions = VncHostOptions.build(scannedHosts, displayHost()); + var labels = new ArrayList(hostOptions.size() + 1); + for (var option : hostOptions) labels.add(hostLabel(ctx, option)); + labels.add(ctx.getString(R.string.create_vm_display_custom)); + ddHost.setAdapter(IconItemAdapter.create(ctx, labels, R.drawable.ic_ip_network)); + ddHost.setOnItemClickListener((parent, view, pos, id) -> { + if (pos < hostOptions.size()) { + vncHost = hostOptions.get(pos).addr; + tilHost.setError(null); + } else { + askCustomHost(ctx); + } + // Same as the two menus above: the entry wrote its own label into the field on its way + // out, and after "custom" that label would sit there reading "Custom..." while the + // dialog is still open. + applyHostText(); + }); + applyHostText(); + } + + /** + * The phone's own addresses, from the daemon. Arrives after the row is built and possibly + * after a config has been loaded over it, so it rebuilds the menu and leaves the selection + * alone -- what was picked stays picked, and only gains the interface name beside it if the + * scan found the same address. + */ + void setScannedHosts(@NonNull List scanned) { + scannedHosts = scanned; + bindHostMenu(); + } + + /** + * The address the menu shows as selected: the stored one, or the wildcard when nothing is + * stored, which is what a host left unset already means to both backends. + */ + @NonNull + private String displayHost() { + return vncHost.isEmpty() ? VncHostOptions.WILDCARD : vncHost; + } + + /** How one entry reads: the address, and the interface it was found on when there is one. */ + @NonNull + private static String hostLabel(@NonNull Context ctx, @NonNull VncHostOptions.Option option) { + return option.ifname.isEmpty() ? option.addr + : ctx.getString(R.string.create_vm_vnc_host_iface_fmt, option.addr, option.ifname); + } + + private void applyHostText() { + var host = displayHost(); + var label = host; + for (var option : hostOptions) + if (option.addr.equals(host)) { + label = hostLabel(ddHost.getContext(), option); + break; + } + ddHost.setText(label, false); + } + + /** + * The host menu's last entry: one address, checked for what would break the option string it + * ends up inside rather than for being reachable. Accepting it adds it to the list, so the + * field shows an entry rather than a value the menu cannot highlight. + */ + private void askCustomHost(@NonNull Context ctx) { + var view = LayoutInflater.from(ctx).inflate(R.layout.dialog_vnc_host, null); + TextInputLayout til = view.findViewById(R.id.til_custom_vnc_host); + TextInputEditText etCustom = view.findViewById(R.id.et_custom_vnc_host); + etCustom.setText(displayHost()); + var dialog = new MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.create_vm_vnc_host_custom_title) + .setView(view) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null) + .show(); + // Wired after show() for the same reason the size dialog is: a refused value has to stay + // on screen with its error, and the listener setPositiveButton takes dismisses regardless. + dialog.getButton(BUTTON_POSITIVE).setOnClickListener(v -> { + var host = getEditText(etCustom).trim(); + if (!VncHostOptions.isLiteral(host)) { + til.setError(ctx.getString(R.string.create_vm_error_invalid_host)); + return; + } + vncHost = host; + tilHost.setError(null); + bindHostMenu(); + dialog.dismiss(); + }); + } + + /** How a size reads, whether the menu offers it, a dialog produced it or a config carried it. */ + @NonNull + private static String sizeLabel(@NonNull Context ctx, int w, int h) { + return ctx.getString(R.string.create_vm_display_size_fmt, w, h); + } + + @NonNull + private static String rateLabel(@NonNull Context ctx, int hz) { + return ctx.getString(R.string.create_vm_display_rate_fmt, hz); + } + + private void applySizeText() { + ddResolution.setText(sizeLabel(ddResolution.getContext(), width, height), false); + } + + private void applyRateText() { + ddRate.setText(rateLabel(ddRate.getContext(), rate), false); + } + + /** The size menu's last entry: two numbers, checked against the bounds the editor saves in. */ + private void askCustomSize(@NonNull Context ctx, @NonNull Runnable onChanged) { + var view = LayoutInflater.from(ctx).inflate(R.layout.dialog_screen_resolution, null); + TextInputEditText etW = view.findViewById(R.id.et_custom_width); + TextInputEditText etH = view.findViewById(R.id.et_custom_height); + etW.setText(String.valueOf(width)); + etH.setText(String.valueOf(height)); + var dialog = new MaterialAlertDialogBuilder(ctx) + .setTitle(R.string.create_vm_display_custom_size_title) + .setView(view) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null) + .show(); + // Wired after show() so a refused value can stay on screen with its error: the listener + // setPositiveButton takes dismisses the dialog whatever it decides. + dialog.getButton(BUTTON_POSITIVE).setOnClickListener(v -> { + var w = bounded(etW, MIN_EDGE, MAX_EDGE); + var h = bounded(etH, MIN_EDGE, MAX_EDGE); + if (w == 0 || h == 0) return; + width = w; + height = h; + tilResolution.setError(null); + applySizeText(); + onChanged.run(); + dialog.dismiss(); + }); + } + + /** The rate menu's last entry, bounded by whichever rate this screen has. */ + private void askCustomRate(@NonNull Context ctx, @NonNull Runnable onChanged) { + var view = LayoutInflater.from(ctx).inflate(R.layout.dialog_screen_rate, null); + TextInputLayout til = view.findViewById(R.id.til_custom_rate); + TextInputEditText etRate = view.findViewById(R.id.et_custom_rate); + til.setHint(ctx.getString(rateHint())); + etRate.setText(String.valueOf(rate)); + var dialog = new MaterialAlertDialogBuilder(ctx) + .setTitle(rateHint()) + .setView(view) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(android.R.string.ok, null) + .show(); + dialog.getButton(BUTTON_POSITIVE).setOnClickListener(v -> { + var hz = bounded(etRate, rateMin(), rateMax()); + if (hz == 0) return; + rate = hz; + tilRate.setError(null); + applyRateText(); + onChanged.run(); + dialog.dismiss(); + }); + } + + /** + * The number typed into a dialog field, or 0 with the reason shown on the field -- the same + * reading, bounds and message the tab applies to every other number on this screen, since the + * value is bound for the same config. + */ + private static int bounded(@NonNull TextInputEditText field, int min, int max) { + field.setError(null); + try { + var value = parseInt(getEditText(field)); + if (value < min || value > max) throw new IllegalArgumentException(); + return value; + } catch (Exception ignored) { + field.setError(field.getContext().getString(R.string.create_vm_error_invalid_number)); + return 0; + } + } + + @StringRes + private int rateHint() { + return gpuScreen + ? R.string.create_vm_display_refresh_rate : R.string.create_vm_display_poll_hz; + } + + private int rateMin() { + return gpuScreen ? 1 : (int) VMScreenConfig.MIN_POLL_HZ; + } + + private int rateMax() { + return gpuScreen ? MAX_REFRESH_RATE : (int) VMScreenConfig.MAX_POLL_HZ; + } + + /** The panel this app is running on, in whatever rotation it is held, or 0x0 if it cannot be + * asked -- which offers the fixed sizes alone rather than inventing a device. */ + @NonNull + private static int[] panelSize(@NonNull Context ctx) { + var wm = ctx.getSystemService(WindowManager.class); + if (wm == null) return new int[]{0, 0}; + var bounds = wm.getMaximumWindowMetrics().getBounds(); + return new int[]{bounds.width(), bounds.height()}; + } + + /** + * Rebuilds the transport menu for [exporter] and settles on a value. + * + *

    Three things decide the menu, and all of them are the edge's rather than this row's: the + * rungs this (screen, exporter) pair has at all, which of them this build can honour, and what + * the highest honourable one is. Rungs that exist but are not built yet are listed and refused + * with a note, so the ladder reads whole; rungs that cannot exist on this pair are absent, + * because offering a choice nobody will ever be able to make is worse than not naming it.

    + * + * @param want the stored ceiling to restore on load, if this edge still offers it; or null on + * a fresh row or an exporter switch, which re-evaluates to the edge's default + * instead of carrying the old pick across. Switching exporter is switching which + * ladder this is, and each ladder's fastest rung is a different one, so the pick + * follows the new edge rather than lingering on a value that was only best for the + * old one. A restore that the edge no longer offers also falls back to the default. + */ + private void applyTransportOptions(@NonNull DisplayExporter exporter, + @Nullable DisplayTransportCap want) { + // No exporter, no edge, no ladder. The row is hidden in that state; leave the menu as it + // was rather than emptying it, since the picker refuses an empty item list outright. + var options = DisplayTransportCap.optionsFor(screenId, exporter); + if (options.length == 0) return; + chooseTransport.setItems(options); + transportMenuBuilt = true; + chooseTransport.setDisabledItems( + chooseTransport.getContext().getString( + R.string.create_vm_option_not_implemented), + DisplayTransportCap.unimplementedFor(screenId, exporter)); + // Where a refused pick lands: this edge's default, not the bottom of its ladder. The + // picker falls back to the head of the list on its own, and the head here is the CPU copy + // -- answering "that rung is not built yet" with the slowest thing this build can do + // rather than the fastest one it can. + var fallback = DisplayTransportCap.defaultFor(screenId, exporter); + chooseTransport.setDefaultItem(fallback); + // want != null is a load: restore the stored ceiling. want == null is a fresh row or an + // exporter switch, and then the ceiling is re-evaluated to the new edge's default rather + // than carried over -- the fastest rung differs per exporter (GPU_HW on VNC, plain GPU on + // native), so carrying the old pick left a screen switched to VNC sitting at plain GPU + // copy when the hardware-encode rung is what it should default to. A stored ceiling this + // edge does not offer, or offers and refuses, lands on that same default: the item set + // above already says which rungs those are, so nothing re-asks it here. + chooseTransport.setSelectedItem(want == null ? fallback : want); + } + + /** Set by the first applyTransportOptions; the picker cannot be read before it. */ + private boolean transportMenuBuilt = false; + + /** The transport currently selected, or null before the menu has ever been built. */ + @Nullable + private DisplayTransportCap currentTransport() { + // Asking the widget before its first setItems is not an IllegalStateException, it is an + // NPE from deep inside (ChooseRowWidget.getSelectedItem -> getPicker() on nothing) -- + // which is exactly how the editor crashed on first open while every unit test, none of + // which inflates the real widget, stayed green. Track the state ourselves instead of + // classifying the widget's failure modes. + if (!transportMenuBuilt) return null; + return chooseTransport.getSelectedItem(); + } + + /** + * Whether the listen address this row holds is one the VM can be started with, said on the row + * that holds it. + * + *

    Nothing the menu offers can fail this and the custom dialog checks before it returns, so + * what it catches is a config that arrived with something else in it -- hand-edited, or written + * back when the field was free text. Worth catching rather than passing on: crosvm refuses a + * host it cannot parse, so the VM would fail to start with the reason only in a log.

    + */ + boolean validateVncHost() { + tilHost.setError(null); + // Empty is the config that names no host, which is legal and means every address; the row + // shows it as the wildcard and writes it back unchanged. + if (vncHost.isEmpty() || VncHostOptions.isLiteral(vncHost)) return true; + tilHost.setError(tilHost.getContext().getString(R.string.create_vm_error_invalid_host)); + return false; + } + + boolean isScreenEnabled() { + return swEnabled.isChecked(); + } + + /** + * Whether this row currently describes a binding the host might blit for -- the same question + * the daemon asks the stored config before it names a blit driver, asked of the live widgets + * so the row that names that driver appears while the user is still choosing. + */ + boolean isGpuBlitBinding() { + var transport = currentTransport(); + return swEnabled.isChecked() && transport != null + && VMScreenConfig.isGpuBlitBinding(getExporter(), transport); + } + + @NonNull + DisplayExporter getExporter() { + return chooseExporter.getSelectedItem(); + } + + void updateVisibility() { + var enabled = swEnabled.isChecked(); + var exporter = getExporter(); + options.setVisibility(enabled ? VISIBLE : GONE); + // No exporter, no edge for a transport to run along, so the row would be a ceiling on + // nothing. Its value is kept while hidden, like the VNC block's. + chooseTransport.setVisibility( + enabled && exporter != DisplayExporter.NONE ? VISIBLE : GONE); + vncOptions.setVisibility( + enabled && exporter == DisplayExporter.VNC ? VISIBLE : GONE); + passwordOptions.setVisibility(swPasswordAuth.isChecked() ? VISIBLE : GONE); + // The one thing the transport ceiling cannot promise: a width whose stride the blit's + // dma-buf import will not take settles a rung lower, silently, and the only clue is a line + // in the console. Say so beside the field that causes it. Nothing is refused and nothing is + // rounded -- the ceiling is honoured either way, it just lands on the CPU copy. + var transport = currentTransport(); + rowWidthCpuFallback.setVisibility( + enabled && transport != null && DisplayTransportCap.cpuFallbackFromWidth( + screenId, exporter, transport, width) ? VISIBLE : GONE); + } + + /** + * Whether the geometry this row holds is one the VM can be saved with, said on the row that + * holds it. + * + *

    Nothing the two menus produce can fail this: every size and rate they offer is in bounds, + * and their dialogs check before they return. What it catches is a config that arrived out of + * bounds -- hand-edited, or written where the limits were different -- and it reports on a row + * that is on screen, because a save refused for a reason the screen does not show is a VM the + * user cannot fix.

    + */ + boolean validateGeometry() { + tilResolution.setError(null); + tilRate.setError(null); + var ctx = tilResolution.getContext(); + if (width < MIN_EDGE || width > MAX_EDGE || height < MIN_EDGE || height > MAX_EDGE) { + tilResolution.setError(ctx.getString(R.string.create_vm_error_invalid_number)); + return false; + } + if (rate < rateMin() || rate > rateMax()) { + tilRate.setError(ctx.getString(R.string.create_vm_error_invalid_number)); + return false; + } + return true; + } + + void load(@NonNull DataItem config) { + var screen = VMScreenConfig.find(config, screenId); + if (screen == null) { + // No entry at all: the screen has never been configured, so the row answers with + // what a new one gets rather than with the sentinel for "watched by nobody". + swEnabled.setChecked(false); + chooseExporter.setSelectedItem(defaultExporter); + applyTransportOptions(defaultExporter, null); + return; + } + swEnabled.setChecked(screen.isEnabled()); + chooseExporter.setSelectedItem(screen.getExporter()); + applyTransportOptions(screen.getExporter(), screen.getTransportCap()); + // Absent in the stored config means on, so an existing VM keeps the devices it had + // without its file being rewritten to say so. + swInputEnabled.setChecked(screen.isInputEnabled()); + width = (int) screen.getWidth(); + height = (int) screen.getHeight(); + applySizeText(); + if (gpuScreen) { + rate = (int) screen.getRefreshRate(); + etDpiH.setText(String.valueOf(screen.getDpiH())); + etDpiV.setText(String.valueOf(screen.getDpiV())); + } else { + rate = (int) screen.getPollHz(); + } + applyRateText(); + // Straight through, empty included: see the field's own note. The menu is rebuilt because + // the entry list depends on what is selected -- a stored address the scan did not find is + // appended so it stays selectable. + vncHost = screen.getVncHost(); + bindHostMenu(); + var port = screen.getVncPort(); + etPort.setText(port > 0 ? String.valueOf(port) : ""); + swPasswordAuth.setChecked(screen.isVncPasswordAuth()); + etPassword.setText(screen.getVncPassword()); + } + + void save(@NonNull DataItem config) { + var screen = VMScreenConfig.of(config, screenId); + // The switch stores the switch, and nothing else. It used to also write NONE over the + // exporter of a screen it was turning off, which read back as a choice the next time the + // editor opened: turning the device on again showed it bound to nobody, with the pick the + // user had made gone. Every reader already asks whether a screen is on before asking what + // it is bound to -- directly (isInputBridgeNeeded, buildScreenExportersCommand) or through + // a filter that does (boundOf, hasAbsoluteInput) -- so "off" needs no second spelling in + // the fields underneath, the same way the geometry below has never needed one. + var exporter = getExporter(); + screen.setEnabled(swEnabled.isChecked()); + screen.setExporter(exporter); + // Written whatever the exporter is, so a rung picked under one exporter is still there + // after a detour through another -- and so a rung that is refused today is remembered for + // the build that can honour it. + var transport = currentTransport(); + if (transport != null) screen.setTransportCap(transport); + screen.setInputEnabled(swInputEnabled.isChecked()); + // The geometry is written whether or not the screen is on, for the same reason the VNC + // block below keeps its values: a size typed once and then switched off should still be + // there when the switch comes back. Only the rows this screen actually has are written -- + // a poll rate on the GPU screen would be a number nothing reads. + screen.setWidth(width); + screen.setHeight(height); + if (gpuScreen) { + screen.setRefreshRate(rate); + screen.setDpiH(parseInt(getEditText(etDpiH))); + screen.setDpiV(parseInt(getEditText(etDpiV))); + } else { + screen.setPollHz(rate); + } + // The VNC block keeps its values even while hidden, so a screen switched to native and + // back finds its port and password where it left them. Only the auth switch is written + // from the live state, since it is what decides whether the password is used at all. + if (exporter == DisplayExporter.VNC) { + screen.setVncHost(vncHost); + var portStr = getEditText(etPort); + screen.setVncPort(portStr.isEmpty() ? -1 : parseInt(portStr)); + var auth = swPasswordAuth.isChecked(); + screen.setVncPasswordAuth(auth); + if (auth) screen.setVncPassword(getEditText(etPassword)); + } + } + + /** The port typed into this row, or -1 for "let the daemon pick one". */ + int typedVncPort() { + return typedPort(etPort); + } + + private static int typedPort(@NonNull TextInputEditText field) { + try { + var portStr = getEditText(field); + return portStr.isEmpty() ? -1 : parseInt(portStr); + } catch (Exception ignored) { + return -1; + } + } + + @NonNull + TextInputEditText portField() { + return etPort; + } + + @NonNull + TextInputEditText dpiHField() { + return etDpiH; + } + + @NonNull + TextInputEditText dpiVField() { + return etDpiV; + } + + boolean isGpuScreen() { + return gpuScreen; + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/graphics/ScreenResolutionOptions.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/graphics/ScreenResolutionOptions.java new file mode 100644 index 00000000..86836ccd --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/edit/graphics/ScreenResolutionOptions.java @@ -0,0 +1,95 @@ +// 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.ui.vm.edit.graphics; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * The sizes the resolution dropdown offers, and the only part of that dropdown that can be decided + * without a device. + * + *

    Two of them are fixed ({@code 1280x720}, {@code 1920x1080}) and two are the phone's own panel + * -- whole, and halved on each axis -- because a guest that matches the panel is the one case where + * nothing has to scale, and half of it is the one that costs a quarter as much to draw. All four are + * landscape: a VM's screen is a desktop's screen, so the larger number is always the width, however + * the phone reports its own.

    + * + *

    They are ordered by area rather than in the order they are named, since "smaller or larger + * than the one I have" is the only comparison between them a user can make at a glance, and the + * phone-derived pair lands in a different place on every device. A size that two rules produce + * appears once -- the list is of sizes, and where a size came from is not something it says.

    + */ +final class ScreenResolutionOptions { + /** The floor the geometry validator enforces; a rule that would produce less offers nothing. */ + static final int MIN_EDGE = 320; + + private static final int[][] PRESETS = {{1280, 720}, {1920, 1080}}; + + static final class Option { + final int width; + final int height; + + Option(int width, int height) { + this.width = width; + this.height = height; + } + + long area() { + return (long) width * height; + } + + boolean is(int w, int h) { + return width == w && height == h; + } + } + + private ScreenResolutionOptions() { + } + + /** + * The dropdown's sizes for a panel of {@code phoneWidth x phoneHeight}, smallest area first. + * + * @param phoneWidth the panel's width in pixels, in whatever rotation it was read; 0 or less + * when there is no panel to ask, which yields the fixed sizes alone rather + * than inventing a device. + * @param phoneHeight the panel's height, likewise. + */ + @NonNull + static List

    Deliberately not {@link #setEnabled(boolean)}: a disabled button swallows the tap, so + * there is nowhere left to explain why the option does nothing. + */ + public void setUnavailable(@Nullable Runnable onTap) { + this.unavailable = onTap; + buttonView.setAlpha(onTap == null ? 1f : 0.5f); + } + @Override public void setEnabled(boolean enabled) { super.setEnabled(enabled); diff --git a/app/src/main/res/drawable/extra_key_bg.xml b/app/src/main/res/drawable/extra_key_bg.xml new file mode 100644 index 00000000..e89794c0 --- /dev/null +++ b/app/src/main/res/drawable/extra_key_bg.xml @@ -0,0 +1,8 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_camera.xml b/app/src/main/res/drawable/ic_camera.xml new file mode 100644 index 00000000..19cc3c22 --- /dev/null +++ b/app/src/main/res/drawable/ic_camera.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_code.xml b/app/src/main/res/drawable/ic_code.xml new file mode 100644 index 00000000..6dcab775 --- /dev/null +++ b/app/src/main/res/drawable/ic_code.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_eye.xml b/app/src/main/res/drawable/ic_eye.xml new file mode 100644 index 00000000..5dc6e47e --- /dev/null +++ b/app/src/main/res/drawable/ic_eye.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_eye_off.xml b/app/src/main/res/drawable/ic_eye_off.xml new file mode 100644 index 00000000..49d626ea --- /dev/null +++ b/app/src/main/res/drawable/ic_eye_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_filter.xml b/app/src/main/res/drawable/ic_filter.xml new file mode 100644 index 00000000..b24f2fd1 --- /dev/null +++ b/app/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_key_backspace.xml b/app/src/main/res/drawable/ic_key_backspace.xml new file mode 100644 index 00000000..e1fec3b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_key_backspace.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_key_enter.xml b/app/src/main/res/drawable/ic_key_enter.xml new file mode 100644 index 00000000..11ba8015 --- /dev/null +++ b/app/src/main/res/drawable/ic_key_enter.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_key_keyboard.xml b/app/src/main/res/drawable/ic_key_keyboard.xml new file mode 100644 index 00000000..63ec2dc4 --- /dev/null +++ b/app/src/main/res/drawable/ic_key_keyboard.xml @@ -0,0 +1,13 @@ + + + + diff --git a/app/src/main/res/drawable/ic_key_shift.xml b/app/src/main/res/drawable/ic_key_shift.xml new file mode 100644 index 00000000..ba38f1bb --- /dev/null +++ b/app/src/main/res/drawable/ic_key_shift.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_key_space.xml b/app/src/main/res/drawable/ic_key_space.xml new file mode 100644 index 00000000..5648c263 --- /dev/null +++ b/app/src/main/res/drawable/ic_key_space.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_key_tab.xml b/app/src/main/res/drawable/ic_key_tab.xml new file mode 100644 index 00000000..756d47ca --- /dev/null +++ b/app/src/main/res/drawable/ic_key_tab.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_key_windows.xml b/app/src/main/res/drawable/ic_key_windows.xml new file mode 100644 index 00000000..b6064b36 --- /dev/null +++ b/app/src/main/res/drawable/ic_key_windows.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_close.xml b/app/src/main/res/drawable/ic_keyboard_close.xml new file mode 100644 index 00000000..b463b6da --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_keyboard_phy.xml b/app/src/main/res/drawable/ic_keyboard_phy.xml new file mode 100644 index 00000000..81990c40 --- /dev/null +++ b/app/src/main/res/drawable/ic_keyboard_phy.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_large_network.xml b/app/src/main/res/drawable/ic_large_network.xml new file mode 100644 index 00000000..0546acb4 --- /dev/null +++ b/app/src/main/res/drawable/ic_large_network.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_microphone.xml b/app/src/main/res/drawable/ic_microphone.xml new file mode 100644 index 00000000..79b1d24f --- /dev/null +++ b/app/src/main/res/drawable/ic_microphone.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_notes.xml b/app/src/main/res/drawable/ic_notes.xml new file mode 100644 index 00000000..b3f5b483 --- /dev/null +++ b/app/src/main/res/drawable/ic_notes.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_speaker.xml b/app/src/main/res/drawable/ic_speaker.xml new file mode 100644 index 00000000..d2b16bc1 --- /dev/null +++ b/app/src/main/res/drawable/ic_speaker.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_split_pane.xml b/app/src/main/res/drawable/ic_split_pane.xml new file mode 100644 index 00000000..253fac09 --- /dev/null +++ b/app/src/main/res/drawable/ic_split_pane.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_tablet_stylus.xml b/app/src/main/res/drawable/ic_tablet_stylus.xml new file mode 100644 index 00000000..9b685a83 --- /dev/null +++ b/app/src/main/res/drawable/ic_tablet_stylus.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_touchscreen.xml b/app/src/main/res/drawable/ic_touchscreen.xml new file mode 100644 index 00000000..09a8fde6 --- /dev/null +++ b/app/src/main/res/drawable/ic_touchscreen.xml @@ -0,0 +1,21 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/ic_video.xml b/app/src/main/res/drawable/ic_video.xml new file mode 100644 index 00000000..a5deb29c --- /dev/null +++ b/app/src/main/res/drawable/ic_video.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/activity_agent_operation.xml b/app/src/main/res/layout/activity_agent_operation.xml index 174f8166..be93c732 100644 --- a/app/src/main/res/layout/activity_agent_operation.xml +++ b/app/src/main/res/layout/activity_agent_operation.xml @@ -81,10 +81,11 @@ android:textColor="#FF6B6B" /> - diff --git a/app/src/main/res/layout/activity_disk_info.xml b/app/src/main/res/layout/activity_disk_info.xml index a8091da8..1c735db5 100644 --- a/app/src/main/res/layout/activity_disk_info.xml +++ b/app/src/main/res/layout/activity_disk_info.xml @@ -28,11 +28,11 @@ - diff --git a/app/src/main/res/layout/activity_disk_operation.xml b/app/src/main/res/layout/activity_disk_operation.xml index 6df4e0c1..dfb75ab5 100644 --- a/app/src/main/res/layout/activity_disk_operation.xml +++ b/app/src/main/res/layout/activity_disk_operation.xml @@ -81,12 +81,12 @@ android:textColor="#FF6B6B" /> - - diff --git a/app/src/main/res/layout/activity_hugepage.xml b/app/src/main/res/layout/activity_hugepage.xml index 98a6e5c8..5443caf6 100644 --- a/app/src/main/res/layout/activity_hugepage.xml +++ b/app/src/main/res/layout/activity_hugepage.xml @@ -357,6 +357,17 @@ android:icon="@drawable/ic_nav_vm" android:text="@string/hugepage_stat_active_vms" /> + + + diff --git a/app/src/main/res/layout/activity_hugepage_advanced.xml b/app/src/main/res/layout/activity_hugepage_advanced.xml new file mode 100644 index 00000000..6db1b6ef --- /dev/null +++ b/app/src/main/res/layout/activity_hugepage_advanced.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_import_lxc_images.xml b/app/src/main/res/layout/activity_import_lxc_images.xml index 06f51f6a..e7a34977 100644 --- a/app/src/main/res/layout/activity_import_lxc_images.xml +++ b/app/src/main/res/layout/activity_import_lxc_images.xml @@ -211,6 +211,14 @@ app:ti_iconButtonHint="@string/disk_create_browse" app:ti_iconButtonIcon="@drawable/ic_open_in" /> + + - + + + + + + + android:hint="@string/create_vm_name_hint" + android:icon="@drawable/ic_edit" + android:inputType="text" /> + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_network_info.xml b/app/src/main/res/layout/activity_network_info.xml index 31f00f0d..7fa794e1 100644 --- a/app/src/main/res/layout/activity_network_info.xml +++ b/app/src/main/res/layout/activity_network_info.xml @@ -28,11 +28,11 @@ - diff --git a/app/src/main/res/layout/activity_vm_console.xml b/app/src/main/res/layout/activity_vm_console.xml index f87ee65d..aacf4ca2 100644 --- a/app/src/main/res/layout/activity_vm_console.xml +++ b/app/src/main/res/layout/activity_vm_console.xml @@ -16,112 +16,11 @@ app:navigationIcon="?attr/homeAsUpIndicator" app:titleTextColor="@android:color/white" /> - - - -