From e2ec44bb07a456576e1578752a77836ee9032ee1 Mon Sep 17 00:00:00 2001 From: HuJK Date: Sun, 5 Jul 2026 10:22:43 +0800 Subject: [PATCH 01/10] hugepage: acquire-mode buttons (v1/v2/v3) with per-mode spinners Expose the module's three acquire algorithms in the UI and reflect which one is running. - Buttons: v1/v2/v3 on the process-list "waiting for acquire" row and a new "Acquire huge pages" item on the main status screen (shared acquire_buttons.xml, same row style as the stat rows). Each writes acquire=1/2/3 via HugePageOps.acquire(int mode, ...). - Per-mode spinner: read acquire_mode from refill_stat (readPoolPages index 5, -1 when the module can't report it). While a run is in flight only the running mode spins (tap = stop) and the other two grey out; an unknown mode (old module) falls back to spinning all three. - Enable only on a real deficit: loaded && pool_want > 0 && avail+served < want. Disabled when not loaded, already at target, or soft-disabled (want=0). - Gentler defaults: pool-size minimum lowered to 0 (ti_min=0MiB); soft-disable writes pool_want=0; Enable/Save auto-fill uses v1 (migrate only, no zram eviction, no reboot risk). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TaHcvnHaZk8qqpKiS25Hbk --- .../droidvm/ui/hugepage/HugePageActivity.java | 546 ++++++------ .../droidvm/ui/hugepage/HugePageModel.java | 807 ++++++++++++++---- .../ui/hugepage/HugePageProcessActivity.java | 265 ++---- .../ui/hugepage/HugePageProcessAdapter.java | 86 +- .../cn/classfun/droidvm/ui/hugepage/Try.java | 48 ++ app/src/main/res/layout/acquire_buttons.xml | 197 +++++ app/src/main/res/layout/activity_hugepage.xml | 33 +- .../main/res/layout/item_hugepage_process.xml | 32 +- app/src/main/res/values-zh-rCN/strings.xml | 12 + app/src/main/res/values-zh-rTW/strings.xml | 12 + app/src/main/res/values/strings.xml | 12 + 11 files changed, 1367 insertions(+), 683 deletions(-) create mode 100644 app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java create mode 100644 app/src/main/res/layout/acquire_buttons.xml 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 d5a6dd7..a4d4be4 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 @@ -5,11 +5,11 @@ import static android.widget.Toast.LENGTH_SHORT; 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.RunUtils.runList; import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; 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.Color; @@ -51,10 +51,7 @@ public final class HugePageActivity extends AppCompatActivity { private static final String TAG = "HugePageActivity"; - private static final String SYSFS_BASE = "/sys/module/gh_hugepage_reserve"; - private static final String SYSFS_PARAMS = pathJoin(SYSFS_BASE, "parameters"); private static final String MAGISK_BASE = "/data/adb/modules/gh-hugepage-reserve"; - private static final String MODULE_PROP = pathJoin(MAGISK_BASE, "module.prop"); private static final String SETTINGS_PROP = pathJoin(MAGISK_BASE, "settings.prop"); private static final String DISABLE_FILE = pathJoin(MAGISK_BASE, "disable"); private static final String CRASH_FILE = pathJoin(MAGISK_BASE, "crash"); @@ -75,7 +72,22 @@ public final class HugePageActivity extends AppCompatActivity { private boolean moduleHasPoolWant = false; private boolean moduleSoftDisabled = false; private boolean moduleAcquiring = false; + // Drives the acquire-mode slots (v1/v2/v3): true while a run is in flight. + // Set optimistically on tap, then reconciled from acquire_active + // (readPoolPages()[3]) by the periodic status refresh. + private boolean mainAcquiring = false; + private int mainAcquireMode = -1; // running v (1/2/3); -1 = unknown (old module) + private boolean wasAcquiring = false; // last-seen acquire_active, for the "done" toast + // Acquire buttons usable only when the module is loaded and there is a deficit + // to fill (avail+served < want); disabled at target, unloaded, or soft-disabled. + private boolean acquireEnabled = false; private MaterialButton btnViewProcesses; + private View btnAcquireV1; + private View btnAcquireV2; + private View btnAcquireV3; + private View progressAcquireV1; + private View progressAcquireV2; + private View progressAcquireV3; private SegmentedBar segPoolBar; private TextView tvPoolUsed; private TextView tvPoolAvail; @@ -105,6 +117,12 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { saveTextColors = btnSavePoolSize.getTextColors(); btnModuleToggle = findViewById(R.id.btn_module_toggle); btnViewProcesses = findViewById(R.id.btn_view_processes); + btnAcquireV1 = findViewById(R.id.btn_acquire_v1); + btnAcquireV2 = findViewById(R.id.btn_acquire_v2); + btnAcquireV3 = findViewById(R.id.btn_acquire_v3); + progressAcquireV1 = findViewById(R.id.progress_acquire_v1); + progressAcquireV2 = findViewById(R.id.progress_acquire_v2); + progressAcquireV3 = findViewById(R.id.progress_acquire_v3); segPoolBar = findViewById(R.id.seg_pool_bar); tvPoolUsed = findViewById(R.id.tv_pool_used); tvPoolAvail = findViewById(R.id.tv_pool_avail); @@ -135,17 +153,122 @@ private void initialize() { // loaded (v6, no knob) -> Disable (rmmod) btnModuleToggle.setOnClickListener(v -> { if (!moduleInstalled) openModulePage(); - else if (!moduleLoaded) doLoad(); - else if (moduleSoftDisabled) doSoftEnable(); - else if (moduleHasPoolWant) doSoftDisable(); - else confirmUnload(); + else if (!moduleLoaded || moduleSoftDisabled) doEnable(); + else if (moduleHasPoolWant) doDisable(); // v7: soft-disable (pool_want=0) + else confirmUnload(); // v6: no soft knob -> confirm rmmod }); btnViewProcesses.setOnClickListener(v -> startActivity( new Intent(this, HugePageProcessActivity.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 + // driven by applyAcquireState(). + // Short-press runs it (gated to the pressable state); long-press opens the + // what-does-this-do dialog with a Run/Cancel choice (always available, even + // when the button is greyed - the buttons stay enabled for that). + btnAcquireV1.setOnClickListener(v -> { if (acquireEnabled && !mainAcquiring) startAcquire(1); }); + btnAcquireV2.setOnClickListener(v -> { if (acquireEnabled && !mainAcquiring) startAcquire(2); }); + btnAcquireV3.setOnClickListener(v -> { if (acquireEnabled && !mainAcquiring) startAcquire(3); }); + btnAcquireV1.setOnLongClickListener(v -> { showAcquireInfo(this, 1, () -> startAcquire(1)); return true; }); + btnAcquireV2.setOnLongClickListener(v -> { showAcquireInfo(this, 2, () -> startAcquire(2)); return true; }); + btnAcquireV3.setOnLongClickListener(v -> { showAcquireInfo(this, 3, () -> startAcquire(3)); return true; }); + View.OnClickListener stopAcquire = v -> stopMainAcquire(); + progressAcquireV1.setOnClickListener(stopAcquire); + progressAcquireV2.setOnClickListener(stopAcquire); + progressAcquireV3.setOnClickListener(stopAcquire); + applyAcquireState(); cardCrashWarning.setOnClickListener(v -> doDismissCrash()); loadPoolSize(); } + /** + * Reflect {@link #mainAcquiring}/{@link #mainAcquireMode} on the three slots: + * idle -> all enabled buttons, no spinners; + * running, mode unknown(-1) -> all three spin (module can't report the mode); + * running, mode 1/2/3 -> that slot spins, the other two are disabled buttons. + * Click listeners are static (set in initialize); a disabled button ignores taps. + */ + private void applyAcquireState() { + applyAcquireSlot(btnAcquireV1, progressAcquireV1, 1); + applyAcquireSlot(btnAcquireV2, progressAcquireV2, 2); + applyAcquireSlot(btnAcquireV3, progressAcquireV3, 3); + } + + private void applyAcquireSlot(View btn, View spinner, int mode) { + boolean spin = mainAcquiring && (mainAcquireMode < 0 || mainAcquireMode == mode); + btn.setVisibility(spin ? GONE : VISIBLE); + // Left enabled so a long-press always opens the info dialog; the short-press + // is gated in its click handler, and the icon+badge greys via alpha when it + // can't run (a run in flight, or no deficit / not loaded). + btn.setAlpha((!mainAcquiring && acquireEnabled) ? 1f : 0.38f); + spinner.setVisibility(spin ? VISIBLE : GONE); + } + + /** Long-press info: what the acquire mode does, with a Run/Cancel choice. */ + static void showAcquireInfo(@NonNull Context ctx, int mode, @NonNull Runnable onRun) { + int modeLabel = mode == 2 ? R.string.hugepage_proc_acquire_v2 + : mode == 3 ? R.string.hugepage_proc_acquire_v3 + : R.string.hugepage_proc_acquire_v1; + int msg = mode == 2 ? R.string.hugepage_acquire_v2_explain + : mode == 3 ? R.string.hugepage_acquire_v3_explain + : R.string.hugepage_acquire_v1_explain; + // Title = "Acquire huge pages" + the mode badge, e.g. "獲取大頁 v1". + String title = ctx.getString(R.string.hugepage_acquire_pages_title) + + " " + ctx.getString(modeLabel); + new MaterialAlertDialogBuilder(ctx) + .setTitle(title) + .setMessage(msg) + .setPositiveButton(R.string.hugepage_acquire_run, (d, w) -> onRun.run()) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + /** + * Start filling the pool with acquire algorithm {@code mode} (see + * {@link HugePageModel#acquire}). Optimistically shows the spinners, fires the + * knob write on a dedicated thread (so the pool executor and its status poll + * stay free), and lets the periodic refresh reconcile the spinner from + * acquire_active. A failed trigger reverts immediately. + */ + private void startAcquire(int mode) { + if (mainAcquiring) return; // already running + Toast.makeText(this, R.string.hugepage_proc_acquire_running, LENGTH_SHORT).show(); + mainAcquiring = true; + mainAcquireMode = mode; // we know which we just started + applyAcquireState(); // immediate spinner feedback + new Thread(() -> { + var res = model.acquire(mode); + if (!res.ok()) { + mainAcquiring = false; + runOnUiThread(() -> { + Toast.makeText(this, R.string.hugepage_refill_failed, + LENGTH_SHORT).show(); + applyAcquireState(); + }); + return; + } + // Triggered: the periodic refresh reads acquire_active and keeps the + // spinner up until the worker clears it. This screen doesn't track + // completion, so if the mode degraded below what was asked, say so now. + final String actualImpl = res.impl != null ? res.impl : ""; + final boolean degraded = res.degraded; + runOnUiThread(() -> { + if (degraded) Toast.makeText(this, + getString(R.string.hugepage_acquire_degraded, actualImpl), + Toast.LENGTH_LONG).show(); + refreshStatus(); + }); + }, "hugepage-acquire").start(); + } + + /** Tapping an acquire spinner interrupts the run; the refresh clears the flag. */ + private void stopMainAcquire() { + runOnPool(() -> { + model.stopAcquire(); + runOnUiThread(this::refreshStatus); + }); + } + private void openModulePage() { var url = "https://github.com/Droid-VM/gh-hugepage-reserve/releases"; var intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); @@ -172,77 +295,26 @@ private void scheduleRefresh() { } private void refreshStatus() { - refreshStatus(false); - } - - /** - * @param forceCaps re-probe module capabilities immediately (bypass the - * cache) - pass true right after loading/unloading so the UI - * reflects the new module state without the cache's lag. - */ - private void refreshStatus(boolean forceCaps) { runOnPool(() -> { - var caps = model.caps(forceCaps); - var moduleInst = shellCheckExists(MODULE_PROP); - var moduleLoaded = caps.loaded; - var moduleDisabled = shellCheckExists(DISABLE_FILE); + // One version-unified read of module + pool state. + var snap = model.state(); var crashStamp = shellCheckExists(CRASH_FILE); - var refillStat = ""; - var servedSummary = ""; - var koAttribution = false; - if (moduleLoaded) try { - refillStat = shellReadFile(pathJoin(SYSFS_PARAMS, "refill_stat")); - // Reconcile + read per-VM served pages so this screen's bar shows - // the exact same per-VM breakdown as the usage screen's bar. - if (caps.koAttribution) { - run("echo 1 > %s/reconcile", SYSFS_PARAMS); - servedSummary = shellReadFile(pathJoin(SYSFS_PARAMS, "served_summary")); - koAttribution = true; + // Per-VM breakdown through the usage ladder (KO attribution, degrading + // to a THP scan of running VMs), so this bar matches the usage screen. + // Each segment is labelled with the friendly VM name from vmMap. + List owners = new ArrayList<>(); + if (snap.loaded) { + for (var e : model.usage(null).entries) { + if (e.pages > 0) owners.add(new long[]{e.pid, e.pages}); } - } catch (Exception e) { - Log.w(TAG, "Failed to read parameters", e); - } - var stats = parseProp(refillStat); - // pid -> friendly VM name, so each used segment is labelled "w11" - // like the usage screen. Old modules without the KO attribution API - // (no served_summary) fall back to a system THP scan of running VMs, - // mirroring the usage screen's "scan" mode, instead of showing the - // unavailable per-VM module-attribution breakdown. - List owners; - Map vmMap; - if (koAttribution) { - owners = parseServedOwners(servedSummary); - vmMap = owners.isEmpty() ? new LinkedHashMap<>() : model.vmNames(false); - } else { - vmMap = moduleLoaded ? model.vmNames(false) : new LinkedHashMap<>(); - owners = model.scanThpPages(vmMap.keySet()); } - runOnUiThread(() -> updateUI( - moduleInst, moduleLoaded, moduleDisabled, crashStamp, stats, owners, vmMap - )); + // Only fetch VM names when there are rows to label. + Map vmMap = owners.isEmpty() + ? new LinkedHashMap<>() : model.vmNames(false); + runOnUiThread(() -> updateUI(snap, crashStamp, owners, vmMap)); }); } - /** Parse served_summary "owner ... pid=N pages=M" lines into {pid, pages}. */ - @NonNull - private List parseServedOwners(@NonNull String raw) { - var owners = new ArrayList(); - for (var line : raw.split("\n")) { - line = line.trim(); - if (!line.startsWith("owner ")) continue; - long pid = -1, pages = 0; - for (var tok : line.split("\\s+")) { - try { - if (tok.startsWith("pid=")) pid = Long.parseLong(tok.substring(4)); - else if (tok.startsWith("pages=")) pages = Long.parseLong(tok.substring(6)); - } catch (NumberFormatException ignored) { - } - } - if (pid > 0 && pages > 0) owners.add(new long[]{pid, pages}); - } - return owners; - } - @NonNull private Map parseProp(@NonNull String raw) { var map = new LinkedHashMap(); @@ -258,86 +330,67 @@ private void setPagesString(@NonNull TextView tv, @StringRes int str, long pages tv.setText(getString(str, pages, SizeUtils.formatSize(pages * PAGE_SIZE))); } - private long getPages(@NonNull Map stats, @NonNull String key) { - var value = stats.get(key); - if (value == null) return 0; - return Long.parseLong(value); - } - private void updateUI( - boolean installed, boolean loaded, - boolean disabled, boolean crashed, - @NonNull Map stats, + @NonNull HugePageModel.Snapshot snap, boolean crashed, @NonNull List owners, @NonNull Map vmMap ) { if (isFinishing()) return; cardCrashWarning.setVisibility(crashed ? VISIBLE : GONE); - cardNotLoaded.setVisibility(loaded ? GONE : VISIBLE); - if (loaded && !stats.isEmpty()) { - rowStatState.setValue(stats.getOrDefault("state", "-")); - rowStatTotalServed.setValue(stats.getOrDefault("total_served", "-")); - rowStatTotalRefilled.setValue(stats.getOrDefault("total_refilled", "-")); - rowStatActiveVms.setValue(stats.getOrDefault("active_vms", "-")); - try { - var poolAvail = getPages(stats, "pool_avail"); - var poolTotal = getPages(stats, "pool_total"); - // "total" shows the desired target (pool_want); grow raises it - // while capacity (pool_total) only rises via acquire. Old - // modules without pool_want fall back to capacity. - var poolWant = getPages(stats, "pool_want"); - if (poolWant <= 0) poolWant = poolTotal; - // Apple-storage-bar style: one labelled colored block per VM - // (used), then the available portion as a track-coloured gap, - // then the waiting-to-acquire (deficit) block pinned flush right. - // Each block draws its label inside if wide enough. - boolean dark = HugePageColor.isDark(this); - int n = owners.size(); - int[] usedColors = new int[n]; - float[] usedValues = new float[n]; - String[] usedLabels = new String[n]; - long seg = 0; - for (int i = 0; i < n; i++) { - int pid = (int) owners.get(i)[0]; - long ownerPages = owners.get(i)[1]; - usedColors[i] = HugePageColor.forPid(pid, dark); - usedValues[i] = ownerPages; - String name = vmMap.get(pid); - if (name == null) name = getString(R.string.hugepage_proc_pid, pid); - // Two stacked lines: label over capacity. - usedLabels[i] = name + "\n" + SizeUtils.formatSize(ownerPages * PAGE_SIZE); - seg += ownerPages; - } - // "used" is the sum of the segments the bar draws, so the caption - // and the bar always agree (one canonical quantity: the per-VM - // served/scanned pages). The kernel 'served' counter can include - // orphaned owner-gone pages that have no segment; those surface in - // the held/available gap rather than as an invisible caption delta. - long used = seg; - long deficit = Math.max(0, poolWant - seg - poolAvail); - // 2x2 caption: used / available on top, total / pool-size below. - // Total = real held reserve (used + avail), shown raw - no clamp - // to the pool size, so a kernel that fails to release on shrink - // shows up as total > the size you set. - var held = used + poolAvail; - setPagesString(tvPoolUsed, R.string.hugepage_stat_pool_used, used); - setPagesString(tvPoolAvail, R.string.hugepage_stat_pool_available, poolAvail); - setPagesString(tvPoolTotal, R.string.hugepage_stat_pool_total, held); - setPagesString(tvPoolSize, R.string.hugepage_stat_pool_size, poolWant); - segPoolBar.setStorage(usedColors, usedValues, usedLabels, - poolAvail, getString(R.string.hugepage_bar_available) - + "\n" + SizeUtils.formatSize(poolAvail * PAGE_SIZE), - HugePageColor.pending(this), deficit, - getString(R.string.hugepage_proc_deficit) - + "\n" + SizeUtils.formatSize(deficit * PAGE_SIZE), - poolWant); - } catch (NumberFormatException e) { - tvPoolUsed.setText("-"); - tvPoolAvail.setText("-"); - tvPoolTotal.setText("-"); - tvPoolSize.setText("-"); - segPoolBar.setData(new int[0], new float[0], 0f); + cardNotLoaded.setVisibility(snap.loaded ? GONE : VISIBLE); + if (snap.loaded && snap.statsOk) { + rowStatState.setValue(snap.state); + rowStatTotalServed.setValue(snap.totalServed); + rowStatTotalRefilled.setValue(snap.totalRefilled); + rowStatActiveVms.setValue(snap.activeVms); + var poolAvail = snap.free; + // "total" shows the desired target - the model's version-unified + // want (pool_want, else v6 pool_target, else current capacity). + var poolWant = snap.targetIdeal; + // Apple-storage-bar style: one labelled colored block per VM + // (used), then the available portion as a track-coloured gap, + // then the waiting-to-acquire (deficit) block pinned flush right. + // Each block draws its label inside if wide enough. + boolean dark = HugePageColor.isDark(this); + int n = owners.size(); + int[] usedColors = new int[n]; + float[] usedValues = new float[n]; + String[] usedLabels = new String[n]; + long seg = 0; + for (int i = 0; i < n; i++) { + int pid = (int) owners.get(i)[0]; + long ownerPages = owners.get(i)[1]; + usedColors[i] = HugePageColor.forPid(pid, dark); + usedValues[i] = ownerPages; + String name = vmMap.get(pid); + if (name == null) name = getString(R.string.hugepage_proc_pid, pid); + // Two stacked lines: label over capacity. + usedLabels[i] = name + "\n" + SizeUtils.formatSize(ownerPages * PAGE_SIZE); + seg += ownerPages; } + // "used" is the sum of the segments the bar draws, so the caption + // and the bar always agree (one canonical quantity: the per-VM + // served/scanned pages). The kernel 'served' counter can include + // orphaned owner-gone pages that have no segment; those surface in + // the held/available gap rather than as an invisible caption delta. + long used = seg; + long deficit = Math.max(0, poolWant - seg - poolAvail); + // 2x2 caption: used / available on top, total / pool-size below. + // Total = real held reserve (used + avail), shown raw - no clamp + // to the pool size, so a kernel that fails to release on shrink + // shows up as total > the size you set. + var held = used + poolAvail; + setPagesString(tvPoolUsed, R.string.hugepage_stat_pool_used, used); + setPagesString(tvPoolAvail, R.string.hugepage_stat_pool_available, poolAvail); + setPagesString(tvPoolTotal, R.string.hugepage_stat_pool_total, held); + setPagesString(tvPoolSize, R.string.hugepage_stat_pool_size, poolWant); + segPoolBar.setStorage(usedColors, usedValues, usedLabels, + poolAvail, getString(R.string.hugepage_bar_available) + + "\n" + SizeUtils.formatSize(poolAvail * PAGE_SIZE), + HugePageColor.pending(this), deficit, + getString(R.string.hugepage_proc_deficit) + + "\n" + SizeUtils.formatSize(deficit * PAGE_SIZE), + poolWant); } else { rowStatState.setValue(getString(R.string.hugepage_stats_unavailable)); rowStatTotalServed.setValue(null); @@ -353,85 +406,102 @@ poolAvail, getString(R.string.hugepage_bar_available) // floppy icon kept) but stays pressable: tapping it then interrupts the // acquire, keeping the current size (see the click handler). pool_want is // settable any time now, so neither button needs to be disabled. - boolean acquiring = loaded && "1".equals(stats.get("acquire_active")); + boolean acquiring = snap.acquiring; moduleAcquiring = acquiring; + // Reconcile the acquire slots from acquire_active + acquire_mode so a run + // that finishes (or was started elsewhere) toggles spinners<->buttons, and + // only the running mode spins. acquire_mode is -1 on old modules -> makes + // all three spin. + int mode = acquiring ? snap.acquireMode : -1; + // A real acquire_active 1 -> 0 edge = the worker finished; announce the + // achieved size (this screen is fire-and-forget - unlike the process list, + // which polls). Track the kernel flag, not the optimistic mainAcquiring, so + // an acquire that never actually started can't fake a "done". + if (wasAcquiring && !acquiring) { + long got = snap.free + snap.lent; + long want = snap.targetIdeal; + Toast.makeText(this, got >= want + ? getString(R.string.hugepage_proc_acquire_full, + SizeUtils.formatSize(want * PAGE_SIZE)) + : getString(R.string.hugepage_proc_acquire_partial, + SizeUtils.formatSize(got * PAGE_SIZE), + SizeUtils.formatSize(want * PAGE_SIZE)), + Toast.LENGTH_LONG).show(); + } + wasAcquiring = acquiring; + if (mainAcquiring != acquiring || mainAcquireMode != mode) { + mainAcquiring = acquiring; + mainAcquireMode = mode; + applyAcquireState(); + } progressSavePoolSize.setVisibility(acquiring ? VISIBLE : GONE); if (acquiring) btnSavePoolSize.setTextColor(Color.TRANSPARENT); else btnSavePoolSize.setTextColor(saveTextColors); - btnSavePoolSize.setEnabled(installed); + btnSavePoolSize.setEnabled(snap.installed); // Install / Enable / Disable. "Disable" on v7 shrinks the pool to one // page (frees memory) but keeps the module loaded so it never loses // per-VM tracking; soft-disabled shows "Enable" again. v6 has no // pool_want knob, so Disable falls back to rmmod. - moduleInstalled = installed; - moduleLoaded = loaded; - moduleHasPoolWant = stats.containsKey("pool_want"); - moduleSoftDisabled = loaded && moduleHasPoolWant - && getPages(stats, "pool_want") <= 1; + moduleInstalled = snap.installed; + moduleLoaded = snap.loaded; + moduleHasPoolWant = snap.hasPoolWant; + moduleSoftDisabled = snap.softDisabled; btnModuleToggle.setEnabled(true); - if (!installed) { + if (!snap.installed) { btnModuleToggle.setText(R.string.hugepage_btn_install); btnModuleToggle.setIconResource(R.drawable.ic_download); - } else if (!loaded || moduleSoftDisabled) { + } else if (!snap.loaded || snap.softDisabled) { btnModuleToggle.setText(R.string.hugepage_btn_enable); btnModuleToggle.setIconResource(R.drawable.ic_start); } else { btnModuleToggle.setText(R.string.hugepage_btn_disable); btnModuleToggle.setIconResource(R.drawable.ic_stop); } - rowModuleEnable.setEnabled(installed); - rowModuleEnable.setChecked(!disabled); + rowModuleEnable.setEnabled(snap.installed); + rowModuleEnable.setChecked(snap.bootEnabled); + + // Acquire buttons usable exactly when there is a deficit to fill. The + // model's version-unified deficit already folds in v6 (no pool_want) and + // soft-disable (want 0), so there is no version branching here. + acquireEnabled = snap.loaded && snap.deficit > 0; + applyAcquireState(); } - private void doLoad() { + /** + * Bring the pool up: load the module (if unloaded) or restore the saved target + * (if soft-disabled), then kick one gentle v1 fill toward it. The model picks + * the version-appropriate path (insmod / pool_want write) and reads the size + * from settings.prop itself. + */ + private void doEnable() { btnModuleToggle.setEnabled(false); runOnPool(() -> { - if (shellCheckExists(SYSFS_BASE)) { - runOnUiThread(() -> { - Toast.makeText(this, R.string.hugepage_already_loaded, - LENGTH_SHORT).show(); - refreshStatus(true); - }); - return; - } - // Read the configured size from settings.prop in Java (rather than - // sourcing it in shell). v7 reads pool_want, older builds pool_target. - var size = "1024"; - if (shellCheckExists(SETTINGS_PROP)) { - var settings = parseProp(shellReadFile(SETTINGS_PROP)); - size = settings.getOrDefault("pool_want", - settings.getOrDefault("pool_target", "1024")); - } - // Insmod compatibly across versions: v7 takes pool_want, older builds - // take pool_target, anything else loads bare. First success wins. - var ko = pathJoin(MAGISK_BASE, "gh_hugepage_reserve.ko"); - var result = - run("insmod \"%s\" pool_want=\"%s\"", ko, size).isSuccess() - || run("insmod \"%s\" pool_target=\"%s\"", ko, size).isSuccess() - || run("insmod \"%s\"", ko).isSuccess(); + var res = model.setRuntimeEnabled(true); + if (res.ok()) model.acquire(1); // GUI drives the fill (v1 = gentlest) runOnUiThread(() -> { - Toast.makeText(this, result - ? R.string.hugepage_loaded - : R.string.hugepage_load_failed, + Toast.makeText(this, res.ok() + ? R.string.hugepage_loaded : R.string.hugepage_load_failed, LENGTH_SHORT).show(); - refreshStatus(true); + refreshStatus(); }); }); } /** - * Soft-disable (v7): shrink the pool to a single page so the reserved memory - * is freed back to the system, but keep the module loaded so it never loses - * per-VM tracking (unlike rmmod). settings.prop is left untouched, so the - * next boot still allocates the configured capacity. + * Take the pool down: soft-disable (shrink pool_want to 0, module stays loaded + * so per-VM tracking survives) where supported, else rmmod (v6). The module + * refuses a resize mid-acquire, so the running worker is stopped first. */ - private void doSoftDisable() { + private void doDisable() { btnModuleToggle.setEnabled(false); runOnPool(() -> { - // The module refuses a resize mid-acquire, so stop the worker first. stopAcquireAndWait(); - run("echo 1 > %s/pool_want", SYSFS_PARAMS); - runOnUiThread(this::refreshStatus); + var res = model.setRuntimeEnabled(false); + runOnUiThread(() -> { + if (!res.ok()) Toast.makeText(this, + R.string.hugepage_unload_failed, LENGTH_SHORT).show(); + refreshStatus(); + }); }); } @@ -442,7 +512,7 @@ private void doSoftDisable() { */ private void interruptAcquire() { runOnPool(() -> { - run("echo 0 > %s/acquire", SYSFS_PARAMS); + model.stopAcquire(); runOnUiThread(this::refreshStatus); }); } @@ -450,15 +520,14 @@ private void interruptAcquire() { /** * Interrupt any running acquire and block (on the pool thread) until the * worker is quiescent. pool_want can't be set while an acquire runs, so call - * this before writing pool_want. The worker can be mid-migration, so this may - * take a moment; bounded so a wedged worker can't hang us forever. + * this before a soft-disable. The worker can be mid-migration, so this may take + * a moment; bounded so a wedged worker can't hang us forever. */ private void stopAcquireAndWait() { - if (!shellCheckExists(pathJoin(SYSFS_PARAMS, "acquire"))) return; - run("echo 0 > %s/acquire", SYSFS_PARAMS); + if (!model.acquiring()) return; // nothing running (also the v6 case) + model.stopAcquire(); for (int i = 0; i < 60; i++) { - var st = parseProp(shellReadFile(pathJoin(SYSFS_PARAMS, "refill_stat"))); - if (!"1".equals(st.get("acquire_active"))) return; + if (!model.acquiring()) return; try { Thread.sleep(500); } catch (InterruptedException ignored) { @@ -467,24 +536,6 @@ private void stopAcquireAndWait() { } } - /** Re-enable a soft-disabled pool: restore the configured size and refill. */ - private void doSoftEnable() { - btnModuleToggle.setEnabled(false); - runOnPool(() -> { - var size = "1024"; - if (shellCheckExists(SETTINGS_PROP)) { - var s = parseProp(shellReadFile(SETTINGS_PROP)); - size = s.getOrDefault("pool_want", - s.getOrDefault("pool_target", "1024")); - } - run("echo %s > %s/pool_want", size, SYSFS_PARAMS); - if (shellCheckExists(pathJoin(SYSFS_PARAMS, "acquire"))) { - run("echo 1 > %s/acquire", SYSFS_PARAMS); - } - runOnUiThread(this::refreshStatus); - }); - } - private void loadPoolSize() { runOnPool(() -> { Map settings; @@ -562,32 +613,19 @@ private void savePoolSize() { LENGTH_SHORT).show()); return; } - // Persist for the next load. Write BOTH keys so the size survives - // whichever module/loader is installed at boot: v7's loader reads - // pool_want, an older (v6) package's loader reads pool_target. - var wroteWant = run("echo 'pool_want=%s' > %s", - pages.toString(), SETTINGS_PROP).isSuccess(); - var wroteTarget = run("echo 'pool_target=%s' >> %s", - pages.toString(), SETTINGS_PROP).isSuccess(); - var result = wroteWant && wroteTarget; - // Apply at runtime via the single pool_want knob: shrink frees excess - // now, grow only raises the target (Acquire fills it). Old modules - // without pool_want just keep the saved value (effective next reboot). - var applied = false; - if (shellCheckExists(pathJoin(SYSFS_PARAMS, "pool_want"))) { - applied = run("echo %s > %s/pool_want", - pages.toString(), SYSFS_PARAMS).isSuccess(); - // Saving also kicks off one acquire so a grown target starts - // filling immediately (a shrink just makes the worker exit at - // once). The 1 s status poll then animates the bar + Save spinner. - if (applied && shellCheckExists(pathJoin(SYSFS_PARAMS, "acquire"))) { - run("echo 1 > %s/acquire", SYSFS_PARAMS); - } - } - var finalApplied = applied; + // Persist for the next load and apply to the running pool where the + // live knob exists (v7); v6's read-only target only lands next boot. + var res = model.saveSize(pages.longValue()); + // A grow leaves a deficit -> kick one v1 fill so the raised target + // starts filling at once (a shrink is applied by the write itself). + // The GUI drives acquire; the model's saveSize deliberately doesn't. + var snap = model.state(); + if (res.ok() && snap.loaded && snap.deficit > 0) model.acquire(1); + boolean okSaved = res.ok(); + boolean appliedNow = "pool_want".equals(res.impl); // live write actually landed runOnUiThread(() -> { - int msg = !result ? R.string.hugepage_pool_size_failed - : finalApplied ? R.string.hugepage_pool_size_applied + int msg = !okSaved ? R.string.hugepage_pool_size_failed + : appliedNow ? R.string.hugepage_pool_size_applied : R.string.hugepage_pool_size_saved; Toast.makeText(this, msg, LENGTH_SHORT).show(); refreshStatus(); @@ -597,13 +635,11 @@ private void savePoolSize() { private void doToggleModule() { var enabled = rowModuleEnable.isChecked(); - if (shellCheckExists(DISABLE_FILE) != enabled) return; + if (shellCheckExists(DISABLE_FILE) != enabled) return; // ignore the programmatic echo runOnPool(() -> { - var result = enabled ? - runList("rm", "-f", DISABLE_FILE) : - runList("touch", DISABLE_FILE); + var res = model.setBootEnabled(enabled); runOnUiThread(() -> { - if (result.isSuccess()) { + if (res.ok()) { var msg = enabled ? R.string.hugepage_module_now_enabled : R.string.hugepage_module_now_disabled; @@ -618,25 +654,11 @@ private void confirmUnload() { new MaterialAlertDialogBuilder(this) .setTitle(R.string.hugepage_stop) .setMessage(R.string.hugepage_stop_confirm) - .setPositiveButton(R.string.hugepage_stop, (d, w) -> doUnload()) + .setPositiveButton(R.string.hugepage_stop, (d, w) -> doDisable()) .setNegativeButton(android.R.string.cancel, null) .show(); } - private void doUnload() { - btnModuleToggle.setEnabled(false); - runOnPool(() -> { - var result = runList("rmmod", "gh_hugepage_reserve").isSuccess(); - runOnUiThread(() -> { - var msg = result ? - R.string.hugepage_unloaded : - R.string.hugepage_unload_failed; - Toast.makeText(this, msg, LENGTH_SHORT).show(); - refreshStatus(true); - }); - }); - } - private void doDismissCrash() { runOnPool(() -> { runList("rm", "-f", CRASH_FILE); 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 0e1958c..8c4b5e9 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 @@ -3,6 +3,7 @@ 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.RunUtils.runList; import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import android.os.SystemClock; @@ -18,131 +19,679 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; import java.util.regex.Pattern; import cn.classfun.droidvm.lib.daemon.DaemonConnection; +import cn.classfun.droidvm.lib.run.RunResult; /** - * Shared huge-page data layer for the status ({@link HugePageActivity}) and - * process ({@link HugePageProcessActivity}) screens. + * The single huge-page facade both screens ({@link HugePageActivity}, + * {@link HugePageProcessActivity}) talk to. * - *

Both screens run a per-second refresh loop, so this class caches the three - * expensive sources with short TTLs so a refresh no longer re-probes root sysfs, - * blocks on the daemon, or re-scans {@code /proc} every single tick: - *

    - *
  • capabilities — one batched root probe of the module's sysfs knobs, - * resolved at most once per {@link #CAPS_TTL_MS} (a static property of the - * loaded {@code .ko}, not something to re-derive every tick);
  • - *
  • VM names — the daemon {@code vm_list} pid→name map, fetched at - * most once per {@link #VM_TTL_MS} and reused (last-known) on a slow daemon - * so labels never blank to raw pids;
  • - *
  • pool_target — the read-only v6 grow target, read once and cached.
  • - *
- * Each screen owns its own instance (separate caches); everything here is - * context-free (pure data), so it never holds an Activity reference. + *

It hides the {@code gh_hugepage_reserve} module's v6/v7 differences: + * callers ask for a high-level operation and never branch on version or poke raw + * sysfs. Version is never decided ahead of time — each mutation is a + * degradation ladder that just tries the best knob and falls back + * (e.g. the migrating {@code acquire} knob → {@code manual_refill}; + * {@code pool_want=0} soft-disable → {@code rmmod}). The internal {@link Try} + * ladder is collapsed to a small {@link Result} ({@code OK / UNSUPPORTED / + * FAILED}) for the GUI — that is the "無法執行就回傳 error" contract. + * + *

Reads are cached with short TTLs so the per-second refresh loop doesn't + * block on the daemon or re-read a static knob every tick. Each screen owns its + * own instance (separate caches); everything here is context-free (pure data) and + * never holds an Activity reference. Mutations do shell I/O — call them off the UI + * thread. */ final class HugePageModel { static final String SYSFS_BASE = "/sys/module/gh_hugepage_reserve"; static final String SYSFS_PARAMS = pathJoin(SYSFS_BASE, "parameters"); - static final String SYSFS_OWNERS = pathJoin(SYSFS_PARAMS, "vm_owners"); - /** A THP huge page is 2 MiB, i.e. 2048 KiB. */ - static final long KB_PER_PAGE = 2048; - static final long PAGE_BYTES = KB_PER_PAGE * 1024; + private static final String SYSFS_OWNERS = pathJoin(SYSFS_PARAMS, "vm_owners"); + private static final String MAGISK_BASE = "/data/adb/modules/gh-hugepage-reserve"; + private static final String MODULE_PROP = pathJoin(MAGISK_BASE, "module.prop"); + private static final String DISABLE_FILE = pathJoin(MAGISK_BASE, "disable"); + private static final String SETTINGS_PROP = pathJoin(MAGISK_BASE, "settings.prop"); + private static final String KO_PATH = pathJoin(MAGISK_BASE, "gh_hugepage_reserve.ko"); + /** A THP huge page is 2 MiB = 2048 KiB. */ + private static final long KB_PER_PAGE = 2048; + /** Default target if settings.prop has none (pages): 1024 x 2 MB = 2 GB. */ + private static final long DEFAULT_WANT_PAGES = 1024; /** meminfo/smaps values are " kB"; strip everything but the digits once. */ private static final Pattern NON_DIGITS = Pattern.compile("[^0-9]"); - private static final long CAPS_TTL_MS = 3000; + private static final long STAT_TTL_MS = 3000; private static final long VM_TTL_MS = 4000; private static final long DAEMON_TIMEOUT_S = 2; /* ================================================================== */ - /* Module capabilities */ + /* Public result / data types */ /* ================================================================== */ - /** Immutable snapshot of what the currently-loaded module supports. */ - static final class Caps { - /** {@code /sys/module/gh_hugepage_reserve} exists (module is loaded). */ - final boolean loaded; - /** served_summary present: per-VM KO attribution API (v7). */ - final boolean koAttribution; - /** pool_want knob present (v7 resizable target). */ - final boolean hasPoolWant; - /** acquire knob present (v7 async grow). */ - final boolean hasAcquire; - /** manual_refill knob present (v6 refill). */ - final boolean hasManualRefill; - - Caps(boolean loaded, boolean koAttribution, boolean hasPoolWant, - boolean hasAcquire, boolean hasManualRefill) { + /** Outcome of a mutation. {@code UNSUPPORTED} = the module/version can't do it. */ + enum Outcome { OK, UNSUPPORTED, FAILED } + + /** Which way a usage list was sourced: module KO attribution, or a THP scan. */ + enum Source { KO, SCAN } + + /** + * Collapsed result of a high-level mutation: the overall outcome, which + * concrete implementation won (or last failed), and a human reason on failure. + */ + static final class Result { + @NonNull final Outcome outcome; + /** Which rung ran (e.g. "acquire", "manual_refill", "soft", "BARE"), or null. */ + @Nullable final String impl; + /** Failure reason, or null on success. */ + @Nullable final String detail; + /** + * True when a fallback rung won: a preferred implementation was + * tried first and failed. This replaces the old ladder's "the trace has + * more than one element" degradation signal - {@link #impl} says which + * fallback, {@code degraded} says that it is one. + */ + final boolean degraded; + + private Result(@NonNull Outcome outcome, @Nullable String impl, + @Nullable String detail, boolean degraded) { + this.outcome = outcome; + this.impl = impl; + this.detail = detail; + this.degraded = degraded; + } + + boolean ok() { + return outcome == Outcome.OK; + } + + static Result ok(@Nullable String impl) { + return new Result(Outcome.OK, impl, null, false); + } + + static Result ok(@Nullable String impl, boolean degraded) { + return new Result(Outcome.OK, impl, null, degraded); + } + + static Result failed(@Nullable String impl, @Nullable String detail) { + return new Result(Outcome.FAILED, impl, detail, false); + } + + static Result unsupported(@Nullable String detail) { + return new Result(Outcome.UNSUPPORTED, null, detail, false); + } + } + + /** A per-VM usage list plus which source produced it. */ + static final class Usage { + @NonNull final List entries; + @NonNull final Source source; + + Usage(@NonNull List entries, @NonNull Source source) { + this.entries = entries; + this.source = source; + } + } + + /** One attributed process: pid, name, huge pages, and live /proc state. */ + static final class UsageEntry { + final int pid; + /** Owner comm (KO) or VM name (scan). */ + @NonNull final String comm; + /** Huge pages attributed: reconciled served count (KO) or THP occupancy (scan). */ + final long pages; + /** /proc run state (R/S/D/...), or '?' if unknown. */ + final char state; + final boolean alive; + + UsageEntry(int pid, @NonNull String comm, long pages, char state, boolean alive) { + this.pid = pid; + this.comm = comm; + this.pages = pages; + this.state = state; + this.alive = alive; + } + } + + /** + * Version-unified snapshot of module + pool state. {@code targetIdeal} is the + * one "target" concept across versions: v7's {@code pool_want}, else v6's + * read-only {@code pool_target}, else current capacity. {@code deficit} is what + * is still missing toward it ({@code targetIdeal − free − lent}); the acquire + * buttons are usable exactly when {@code loaded && deficit > 0}. + */ + static final class Snapshot { + final boolean installed; // Magisk module files present + final boolean loaded; // insmod'd (sysfs node exists) + final boolean statsOk; // refill_stat was readable (false on a transient failure) + final boolean bootEnabled; // will load next boot (no Magisk disable file) + final long targetIdeal; // unified target (want): pool_want ?? pool_target ?? built + final long built; // pool_total (capacity assembled) + final long free; // pool_avail (in the pool now) + final long lent; // served (out to VMs); 0 if the module can't report it (v6) + final long deficit; // max(0, targetIdeal − free − lent) + final boolean acquiring; // an acquire worker is running + final int acquireMode; // which mode (1/2/3), or -1 if the module can't report it + final boolean hasPoolWant; // pool_want knob reported (v7 runtime-resizable) + final boolean softDisabled; // v7 pool_want <= 1 (reserve released, module stays loaded) + // Raw display strings from refill_stat (pass-through, "-" when absent). + @NonNull final String state; + @NonNull final String totalServed; + @NonNull final String totalRefilled; + @NonNull final String activeVms; + + private Snapshot(boolean installed, boolean loaded, boolean statsOk, boolean bootEnabled, + long targetIdeal, long built, long free, long lent, long deficit, + boolean acquiring, int acquireMode, boolean hasPoolWant, + boolean softDisabled, @NonNull String state, @NonNull String totalServed, + @NonNull String totalRefilled, @NonNull String activeVms) { + this.installed = installed; this.loaded = loaded; - this.koAttribution = koAttribution; + this.statsOk = statsOk; + this.bootEnabled = bootEnabled; + this.targetIdeal = targetIdeal; + this.built = built; + this.free = free; + this.lent = lent; + this.deficit = deficit; + this.acquiring = acquiring; + this.acquireMode = acquireMode; this.hasPoolWant = hasPoolWant; - this.hasAcquire = hasAcquire; - this.hasManualRefill = hasManualRefill; + this.softDisabled = softDisabled; + this.state = state; + this.totalServed = totalServed; + this.totalRefilled = totalRefilled; + this.activeVms = activeVms; } } - @Nullable - private volatile Caps capsCache; - private volatile long capsAtMs = Long.MIN_VALUE; + /* ================================================================== */ + /* Public high-level API */ + /* ================================================================== */ /** - * Module capabilities, cached for {@link #CAPS_TTL_MS}. Resolving this from a - * cache (instead of re-probing every refresh) is what stops a single flaky - * root {@code test -e} from flapping the whole view between KO and scan mode. - * Pass {@code force = true} right after an action that changes the module - * (load / unload / soft-disable) to re-probe immediately. + * Version-unified module + pool snapshot. Never throws; when the module isn't + * loaded the pool fields are zero and {@code loaded == false}. */ @NonNull - Caps caps(boolean force) { - long now = SystemClock.elapsedRealtime(); - var c = capsCache; - if (!force && c != null && now - capsAtMs < CAPS_TTL_MS) return c; - c = probeCaps(); - capsCache = c; - capsAtMs = now; - return c; + Snapshot state() { + boolean installed = existsSticky(MODULE_PROP); + boolean bootEnabled = !shellCheckExists(DISABLE_FILE); + boolean loaded = existsSticky(SYSFS_BASE); + if (!loaded) { + return new Snapshot(installed, false, false, bootEnabled, + 0, 0, 0, 0, 0, false, -1, false, false, "-", "-", "-", "-"); + } + var s = parseProp(safeRead(pathJoin(SYSFS_PARAMS, "refill_stat"))); + boolean statsOk = !s.isEmpty(); // empty == the read failed transiently + boolean hasWant = s.containsKey("pool_want"); + long built = getLong(s, "pool_total", 0); + long free = getLong(s, "pool_avail", 0); + long lent = getLong(s, "served", 0); + long rawWant = getLong(s, "pool_want", -1); + long want = rawWant; + if (want < 0) want = poolTarget(); // v6: separate read-only knob + if (want < 0) want = built; // last resort: current capacity + long deficit = Math.max(0, want - free - lent); + boolean acquiring = "1".equals(s.get("acquire_active")); + int mode = (int) getLong(s, "acquire_mode", -1); + boolean softDisabled = hasWant && rawWant <= 1; + return new Snapshot(installed, true, statsOk, bootEnabled, + want, built, free, lent, deficit, acquiring, mode, hasWant, softDisabled, + s.getOrDefault("state", "-"), s.getOrDefault("total_served", "-"), + s.getOrDefault("total_refilled", "-"), s.getOrDefault("active_vms", "-")); } /** - * One batched root round-trip that tests every knob at once (5 {@code test -e} - * in a single {@code run}, instead of one exec per knob). If it comes back - * "not loaded" we retry once, since a lone root {@code test -e} can flake - * under load and we don't want to spuriously report the module gone. + * Whether an acquire worker is currently running. Reads only {@code refill_stat} + * (no module / boot-file probing), so it is cheap enough for the tight poll + * loops where a full {@link #state()} every 500 ms would be wasteful. + */ + boolean acquiring() { + return "1".equals(parseProp(safeRead( + pathJoin(SYSFS_PARAMS, "refill_stat"))).get("acquire_active")); + } + + /** + * Per-VM huge-page usage. Prefers KO attribution (the module's reconciled + * {@code served_summary} + {@code vm_owners}); degrades to a THP scan of the + * daemon's running VMs. Pass a {@code pref} to pin a source (the UI lets the + * user choose), or {@code null} to walk the ladder. */ @NonNull - private Caps probeCaps() { - var caps = probeCapsOnce(); - if (!caps.loaded) caps = probeCapsOnce(); // absorb a flaky probe - return caps; + Usage usage(@Nullable Source pref) { + var log = new ArrayList>>(); + if (rung(pref, log, Source.KO, this::usageFromKo)) return usageOf(log); + rung(pref, log, Source.SCAN, this::usageFromScan); + return usageOf(log); } + /** Whether KO attribution is currently available (the {@code served_summary} knob exists). */ + boolean koAvailable() { + return existsSticky(pathJoin(SYSFS_PARAMS, "served_summary")); + } + + /** + * Save a new pool target of {@code pages}. Persists it to settings.prop (for + * the next boot) and applies it to the running pool if the live {@code pool_want} + * knob exists; on v6 (read-only {@code pool_target}) only the persist takes + * effect. Does not fire an acquire — the caller drives that from the + * resulting {@link Snapshot#deficit}. + */ @NonNull - private Caps probeCapsOnce() { - String raw; + Result saveSize(long pages) { + var persisted = writeSettings(pages); + if (!persisted.ok()) return Result.failed("settings", persisted.error); + // Best-effort live apply; the impl reports whether it actually took effect + // on the running pool ("pool_want") or only persisted for next boot + // ("settings" - v6's read-only target, or a rejected write). + var live = writeKnob("pool_want", Long.toString(pages)); + // Degraded when the live write didn't land (v6 read-only target, or a + // rejected write): only the next-boot persist took effect. + return Result.ok(live.ok() ? "pool_want" : "settings", !live.ok()); + } + + /** + * Bring the running pool up (true) or down (false), choosing the deepest action + * the module supports: + *

    + *
  • enable: not loaded → insmod (targeting the saved size); loaded → + * restore the target by writing the saved {@code pool_want} (v6 loaded is + * already active, so this is a no-op there);
  • + *
  • disable: try {@code pool_want=0} (soft — frees the reserve but + * keeps per-VM tracking); if that write can't happen (v6 has no such knob) + * fall back to {@code rmmod}.
  • + *
+ */ + @NonNull + Result setRuntimeEnabled(boolean on) { + if (on) { + if (!existsSticky(SYSFS_BASE)) { + return collapse(loadLadder(savedWantPages())); // insmod + } + // Loaded: restore the target. Succeeds on v7; on v6 there's no pool_want + // and a loaded module is already active, so treat a failed write as "已啟用". + var t = writeKnob("pool_want", Long.toString(savedWantPages())); + return t.ok() ? Result.ok("soft-enable") : Result.ok("already-active"); + } + // Disable. Pick soft-vs-rmmod by *capability* (does the pool_want knob + // exist?), NOT by write success: a v7 soft-disable can also fail + // transiently (the module refuses a resize mid-acquire, or a root hiccup), + // and escalating that to rmmod would destroy the per-VM tracking that + // soft-disable exists to preserve. So v7 only ever soft-disables (a failed + // write is reported as FAILED to retry); only v6 (no knob) falls to rmmod. + if (existsSticky(pathJoin(SYSFS_PARAMS, "pool_want"))) { + var soft = writeKnob("pool_want", "0"); + return soft.ok() ? Result.ok("soft") : Result.failed("soft", soft.error); + } + return collapse(unloadLadder()); + } + + /** + * Enable (true) or disable (false) the module for the next boot via the + * Magisk {@code disable} marker file. Independent of the running module. + */ + @NonNull + Result setBootEnabled(boolean on) { + var r = on + ? runList("rm", "-f", DISABLE_FILE) + : run("touch %s", DISABLE_FILE); + return r.isSuccess() + ? Result.ok(on ? "enable-boot" : "disable-boot") + : Result.failed("disable-file", reason(r, "disable")); + } + + /** + * Fill the pool toward its target with acquire algorithm {@code mode} + * (1 = original scan, 2 = migrate + system reclaim, 3 = migrate + per-block + * evict). Prefers the migrating {@code acquire} knob; on any failure (v6 has no + * such knob, or -ENOSYS where this kernel can't run that mode) degrades to + * {@code manual_refill}. Fire-and-return — poll {@link #state()} for completion. + */ + @NonNull + Result acquire(int mode) { + // Drop the whole VM page cache + reclaimable slab first so more 2 MB windows + // are already free (or trivially assemblable) before the sweep - the module's + // own acquire_drop_slab covers slab only, not the page cache. Best-effort: a + // failed drop doesn't block the acquire. + run("echo 3 > /proc/sys/vm/drop_caches"); + // Walk the requested mode down to the gentlest migrating mode: the module + // returns -ENOSYS for a mode whose symbols didn't resolve (e.g. v3 on a + // kernel lacking the folio/reclaim symbols, or v2 with no alloc_contig_range), + // so try mode -> ... -> 1 before giving up on migration and falling back to a + // plain manual_refill. Any acquire mode is pollable via acquire_active, so the + // winning impl is uniformly "acquire" (the running mode surfaces in acquire_mode). + for (int m = mode; m >= 1; m--) { + if (writeKnob("acquire", Integer.toString(m)).ok()) { + // impl names the actual migrating mode that ran ("v1".."v3"); + // degraded when it's below what was asked for. + return Result.ok("v" + m, m != mode); + } + } + var refill = writeKnob("manual_refill", "1"); + return refill.ok() + ? Result.ok("manual_refill", true) + : Result.unsupported("no acquire / manual_refill knob"); + } + + /** Interrupt a running acquire (write 0 to the knob). No worker exists on v6. */ + @NonNull + Result stopAcquire() { + var t = writeKnob("acquire", "0"); + return t.ok() ? Result.ok("acquire") : Result.unsupported(t.error); + } + + /* ================================================================== */ + /* Ladder plumbing (internal) */ + /* ================================================================== */ + + /** insmod ladder: both keys, then {@code pool_want=} (v7), {@code pool_target=} (v6), bare. */ + private enum LoadImpl { BOTH, POOL_WANT, POOL_TARGET, BARE } + + /** Marker for single-implementation actions. */ + private enum Only { DEFAULT } + + private static boolean rung( + @Nullable I only, @NonNull List> log, + @NonNull I impl, @NonNull Supplier> action + ) { + if (only != null && only != impl) return false; + var t = action.get(); + log.add(t); + return t.ok(); + } + + /** Collapse a finished ladder trace into a {@link Result}. */ + @NonNull + private static Result collapse(@NonNull List> log) { + if (log.isEmpty()) return Result.failed(null, "no attempt"); + var last = log.get(log.size() - 1); + boolean degraded = log.size() > 1; // earlier rungs failed before this won + return last.ok() + ? Result.ok(String.valueOf(last.impl), degraded) + : Result.failed(String.valueOf(last.impl), last.error); + } + + @NonNull + private static Usage usageOf(@NonNull List>> log) { + var last = log.isEmpty() ? null : log.get(log.size() - 1); + if (last != null && last.ok() && last.value != null) { + return new Usage(last.value, last.impl); + } + return new Usage(new ArrayList<>(), Source.SCAN); + } + + /* ================================================================== */ + /* Module lifecycle ladders */ + /* ================================================================== */ + + @NonNull + private List> loadLadder(long pages) { + var log = new ArrayList>(); + var w = "pool_want=\"" + pages + "\""; + var t = "pool_target=\"" + pages + "\""; + // Pass BOTH size params first. A lenient kernel silently ignores the param + // the module doesn't have, so it still gets the right size via the one it + // does - pool_want (v7) or pool_target (v6). This is essential: on a v6 + // module `pool_want=` alone loads fine (returns 0) but is ignored, leaving + // the pool at its compiled 1024 default - the ladder must not stop there. + // Strict kernels reject the unknown param, so fall back to each key alone, + // then a bare (default-size) load. + if (rung(null, log, LoadImpl.BOTH, + () -> insmod(LoadImpl.BOTH, w + " " + t))) return log; + if (rung(null, log, LoadImpl.POOL_WANT, + () -> insmod(LoadImpl.POOL_WANT, w))) return log; + if (rung(null, log, LoadImpl.POOL_TARGET, + () -> insmod(LoadImpl.POOL_TARGET, t))) return log; + rung(null, log, LoadImpl.BARE, () -> insmod(LoadImpl.BARE, null)); + return log; + } + + private static Try insmod(@NonNull LoadImpl impl, @Nullable String arg) { + var r = (arg == null) + ? run("insmod \"%s\"", KO_PATH) + : run("insmod \"%s\" %s", KO_PATH, arg); + return r.isSuccess() ? Try.ok(impl, null) : Try.fail(impl, reason(r, "insmod")); + } + + @NonNull + private List> unloadLadder() { + var log = new ArrayList>(); + rung(null, log, Only.DEFAULT, () -> { + var r = runList("rmmod", "gh_hugepage_reserve"); + return r.isSuccess() + ? Try.ok(Only.DEFAULT, null) + : Try.fail(Only.DEFAULT, reason(r, "rmmod")); + }); + return log; + } + + /** The configured target from settings.prop (pool_want, legacy pool_target). */ + private long savedWantPages() { + if (existsSticky(SETTINGS_PROP)) { + var s = parseProp(safeRead(SETTINGS_PROP)); + var v = s.getOrDefault("pool_want", s.get("pool_target")); + if (v != null) try { + return Long.parseLong(v.trim()); + } catch (NumberFormatException ignored) { + } + } + return DEFAULT_WANT_PAGES; + } + + /* ================================================================== */ + /* Shell helpers */ + /* ================================================================== */ + + /** Write a knob; returns a bare success/fail Try (single-impl call sites). */ + private static Try writeKnob(@NonNull String knob, @NonNull String value) { + return writeKnobTry(Only.DEFAULT, knob, value); + } + + /** Write a knob under the given impl marker; the Try carries the errno reason. */ + private static Try writeKnobTry( + @NonNull I impl, @NonNull String knob, @NonNull String value + ) { + var r = run("echo %s > %s/%s", value, SYSFS_PARAMS, knob); + return r.isSuccess() + ? Try.ok(impl, null) + : Try.fail(impl, reason(r, knob)); + } + + /** Persist the target to settings.prop under both keys (whichever loader reads). */ + private static Try writeSettings(long pages) { + var a = run("echo 'pool_want=%s' > %s", pages, SETTINGS_PROP); + var b = run("echo 'pool_target=%s' >> %s", pages, SETTINGS_PROP); + return (a.isSuccess() && b.isSuccess()) + ? Try.ok(Only.DEFAULT, null) + : Try.fail(Only.DEFAULT, reason(a.isSuccess() ? b : a, "settings.prop")); + } + + @NonNull + private static String reason(@NonNull RunResult r, @NonNull String what) { + var msg = r.getErrString().trim(); + if (msg.isEmpty()) msg = r.getOutString().trim(); + if (msg.isEmpty()) msg = "exit " + r.getCode(); + return what + ": " + msg; + } + + /** + * {@code test -e} that retries once on a negative. A lone root {@code test -e} + * can flake under load; retrying only the negative absorbs that transient miss + * without caching a possibly-stale positive. + */ + private static boolean existsSticky(@NonNull String path) { + return shellCheckExists(path) || shellCheckExists(path); + } + + @NonNull + private static String safeRead(@NonNull String path) { try { - raw = run( - "for f in base served_summary pool_want acquire manual_refill; do " - + "if [ \"$f\" = base ]; then t=%1$s; else t=%2$s/$f; fi; " - + "[ -e \"$t\" ] && echo 1 || echo 0; done", - SYSFS_BASE, SYSFS_PARAMS - ).getOutString(); + return shellReadFile(path); } catch (Exception e) { - raw = ""; + return ""; } - var f = raw.split("\n"); - boolean loaded = flag(f, 0); - boolean served = flag(f, 1); - return new Caps(loaded, loaded && served, flag(f, 2), flag(f, 3), flag(f, 4)); } - private static boolean flag(@NonNull String[] lines, int i) { - return i < lines.length && "1".equals(lines[i].trim()); + @NonNull + private static Map parseProp(@NonNull String raw) { + var map = new LinkedHashMap(); + for (var line : raw.split("\n")) { + var parts = line.split("=", 2); + if (parts.length == 2) map.put(parts[0].trim(), parts[1].trim()); + } + return map; + } + + private static long getLong(@NonNull Map m, @NonNull String key, long def) { + var v = m.get(key); + if (v == null) return def; + try { + return Long.parseLong(v.trim()); + } catch (NumberFormatException e) { + return def; + } } /* ================================================================== */ - /* VM name resolution (daemon vm_list) */ + /* Pool target (v6 read-only knob, cached) */ + /* ================================================================== */ + + private volatile long poolTargetCache = -1; + private volatile long poolTargetAtMs = Long.MIN_VALUE; + + /** + * v6 grow target from the read-only {@code pool_target} param, cached with a + * short TTL (static until the module is reloaded). Returns -1 if unavailable. + */ + private long poolTarget() { + long now = SystemClock.elapsedRealtime(); + if (poolTargetAtMs != Long.MIN_VALUE && now - poolTargetAtMs < STAT_TTL_MS) { + return poolTargetCache; + } + long v = -1; + try { + var t = shellReadFile(pathJoin(SYSFS_PARAMS, "pool_target")).trim(); + if (!t.isEmpty()) v = Long.parseLong(t); + } catch (Exception ignored) { + } + poolTargetCache = v; + poolTargetAtMs = now; + return v; + } + + /* ================================================================== */ + /* Usage sources (KO attribution / THP scan) */ + /* ================================================================== */ + + private Try> usageFromKo() { + if (!koAvailable()) return Try.fail(Source.KO, "KO attribution knobs absent"); + try { + // Reconcile, then read reconciled per-owner live page counts. + run("echo 1 > %s/reconcile", SYSFS_PARAMS); + var livePages = new LinkedHashMap(); + for (var ln : safeRead(pathJoin(SYSFS_PARAMS, "served_summary")).split("\n")) { + ln = ln.trim(); + if (!ln.startsWith("owner ")) continue; + int pid = -1; + long pages = 0; + for (var tok : ln.split("\\s+")) { + if (tok.startsWith("pid=")) pid = parseIntSafe(tok.substring(4)); + else if (tok.startsWith("pages=")) pages = parseLongSafe(tok.substring(6)); + } + if (pid > 0) livePages.put(pid, pages); + } + var owners = parseOwners(safeRead(SYSFS_OWNERS)); + var pids = new ArrayList(); + for (var o : owners) pids.add(o.pid); + var procInfo = readProcInfo(pids); + + var out = new ArrayList(); + for (var o : owners) { + var info = procInfo.get(o.pid); + char state = info != null ? info.state : '?'; + boolean alive = info != null && info.alive; + // Occupancy = reconciled served count (not the /proc scan; /proc is + // only used for run state + liveness). + Long served = livePages.get(o.pid); + out.add(new UsageEntry(o.pid, o.comm, served != null ? served : 0L, state, alive)); + } + out.sort((a, b) -> Long.compare(b.pages, a.pages)); + return Try.ok(Source.KO, out); + } catch (Exception e) { + return Try.fail(Source.KO, "KO read: " + e.getMessage()); + } + } + + private Try> usageFromScan() { + // Running VMs (pid -> name) come from the daemon; scan only those, not all + // of /proc (system-wide THP is noise unrelated to the pool). + var vmMap = vmNames(false); + if (vmMap.isEmpty()) return Try.fail(Source.SCAN, "no running VM pids"); + var procInfo = readProcInfo(vmMap.keySet()); + + var out = new ArrayList(); + for (var e : vmMap.entrySet()) { + int pid = e.getKey(); + var info = procInfo.get(pid); + char state = info != null ? info.state : '?'; + long thpKb = info != null ? info.thpKb : -1; + long pages = thpKb > 0 ? thpKb / KB_PER_PAGE : 0; + boolean alive = info != null && info.alive; + out.add(new UsageEntry(pid, e.getValue(), pages, state, alive)); + } + out.sort((a, b) -> Long.compare(b.pages, a.pages)); + return Try.ok(Source.SCAN, out); + } + + /* ---- vm_owners parsing ("pid= served= comm=") ---- */ + + private static final class Owner { + final int pid; + @NonNull final String comm; + + Owner(int pid, @NonNull String comm) { + this.pid = pid; + this.comm = comm; + } + } + + @NonNull + private static List parseOwners(@NonNull String raw) { + var list = new ArrayList(); + for (var line : raw.split("\n")) { + line = line.trim(); + if (line.isEmpty()) continue; + int pid = -1; + String comm = "?"; + int commIdx = line.indexOf("comm="); + if (commIdx >= 0) comm = line.substring(commIdx + 5).trim(); + for (var tok : line.split("\\s+")) { + if (tok.startsWith("pid=")) pid = parseIntSafe(tok.substring(4)); + } + if (pid > 0) list.add(new Owner(pid, comm)); + } + return list; + } + + private static int parseIntSafe(@NonNull String s) { + try { + return Integer.parseInt(s.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + private static long parseLongSafe(@NonNull String s) { + try { + return Long.parseLong(s.trim()); + } catch (NumberFormatException e) { + return 0; + } + } + + /* ================================================================== */ + /* VM name resolution (daemon vm_list), cached */ /* ================================================================== */ @NonNull @@ -153,10 +702,8 @@ private static boolean flag(@NonNull String[] lines, int i) { /** * Best-effort pid→VM-name map from the daemon, cached for * {@link #VM_TTL_MS}. Within the TTL the cached map is returned without - * contacting the daemon, so the refresh loop no longer blocks up to 2s every - * tick. On a fetch that times out the last known map is kept (labels stay - * stable instead of blanking to raw pids). Pass {@code force = true} to - * bypass the cache (e.g. right after killing a process). + * contacting the daemon. On a timeout the last known map is kept (labels stay + * stable instead of blanking to raw pids). Pass {@code force} to bypass the cache. */ @NonNull Map vmNames(boolean force) { @@ -171,16 +718,6 @@ Map vmNames(boolean force) { return vmNames; } - /** - * One blocking daemon {@code vm_list} round-trip (bounded to - * {@link #DAEMON_TIMEOUT_S}). Returns {@code null} on timeout/error so the - * caller can distinguish "no answer" from a genuine empty VM list. - * - *

The map is published through an {@link AtomicReference} set inside the - * callback, so the caller either sees a fully-built map or {@code null} — - * never a half-populated one, even when the response lands right at the - * timeout boundary. - */ @Nullable private Map fetchVmMap() { var result = new AtomicReference>(null); @@ -291,86 +828,4 @@ Map readProcInfo(@NonNull Collection pids) { if (curPid > 0) map.put(curPid, new ProcInfo(state, thp, alive)); return map; } - - /** - * Per-VM THP occupancy in pages for the given pids, as {pid, pages} - * rows, dropping any VM with under one full huge page. Used by the status - * screen's scan-fallback bar; a thin wrapper over {@link #readProcInfo}. - */ - @NonNull - List scanThpPages(@NonNull Collection pids) { - var result = new ArrayList(); - var procInfo = readProcInfo(pids); - for (var pid : pids) { - var info = procInfo.get(pid); - if (info == null || info.thpKb <= 0) continue; - long pages = info.thpKb / KB_PER_PAGE; - if (pages > 0) result.add(new long[]{pid, pages}); - } - return result; - } - - /* ================================================================== */ - /* Pool stats */ - /* ================================================================== */ - - private volatile long poolTargetCache = -1; - private volatile long poolTargetAtMs = Long.MIN_VALUE; - - /** - * v6 grow target from the read-only {@code pool_target} param, cached with a - * short TTL (it is static until the module is reloaded / the app restarts). - * Returns -1 if unavailable. This is what stops the v6 refresh loop from - * re-{@code cat}-ing the same static file every tick. - */ - private long poolTarget() { - long now = SystemClock.elapsedRealtime(); - if (poolTargetAtMs != Long.MIN_VALUE && now - poolTargetAtMs < CAPS_TTL_MS) { - return poolTargetCache; - } - long v = -1; - try { - var t = shellReadFile(pathJoin(SYSFS_PARAMS, "pool_target")).trim(); - if (!t.isEmpty()) v = Long.parseLong(t); - } catch (Exception ignored) { - } - poolTargetCache = v; - poolTargetAtMs = now; - return v; - } - - /** - * Read the pool counters from {@code refill_stat}. - * Returns {@code {total, avail, want, acquiring, served}} in pages, or - * {@code null} if the module isn't loaded / stats are unreadable. {@code want} - * falls back to v6's cached {@code pool_target}, then to capacity. - */ - @Nullable - long[] readPoolPages() { - try { - if (!shellCheckExists(SYSFS_BASE)) return null; - var raw = shellReadFile(pathJoin(SYSFS_PARAMS, "refill_stat")); - long total = -1, avail = 0, want = -1, acquiring = 0, served = 0; - for (var line : raw.split("\n")) { - line = line.trim(); - if (line.startsWith("pool_total=")) { - total = Long.parseLong(line.substring(11).trim()); - } else if (line.startsWith("pool_avail=")) { - avail = Long.parseLong(line.substring(11).trim()); - } else if (line.startsWith("pool_want=")) { - want = Long.parseLong(line.substring(10).trim()); - } else if (line.startsWith("acquire_active=")) { - acquiring = Long.parseLong(line.substring(15).trim()); - } else if (line.startsWith("served=")) { - served = Long.parseLong(line.substring(7).trim()); - } - } - if (total < 0) return null; - if (want < 0) want = poolTarget(); // v6: separate read-only knob - if (want < 0) want = total; - return new long[]{total, avail, want, acquiring, served}; - } catch (Exception e) { - return null; - } - } } 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 1f1bc9e..1bdce49 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 @@ -9,8 +9,6 @@ import static cn.classfun.droidvm.lib.utils.ProcessUtils.SIGKILL; import static cn.classfun.droidvm.lib.utils.ProcessUtils.SIGTERM; import static cn.classfun.droidvm.lib.utils.ProcessUtils.shellKillProcess; -import static cn.classfun.droidvm.lib.utils.RunUtils.run; -import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; import android.os.Bundle; @@ -34,9 +32,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Map; import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.size.SizeUtils; @@ -50,9 +46,6 @@ public final class HugePageProcessActivity extends AppCompatActivity implements HugePageProcessAdapter.Listener { private static final String TAG = "HugePageProcessActivity"; - private static final String SYSFS_BASE = "/sys/module/gh_hugepage_reserve"; - private static final String SYSFS_PARAMS = pathJoin(SYSFS_BASE, "parameters"); - private static final String SYSFS_OWNERS = pathJoin(SYSFS_PARAMS, "vm_owners"); private static final long REFRESH_INTERVAL_MS = 2000; /** Acquire progress poll: ~500 ms ticks, capped so a wedged run can't poll forever. */ private static final long ACQUIRE_POLL_MS = 500; @@ -73,6 +66,7 @@ public final class HugePageProcessActivity extends AppCompatActivity private boolean koWasPresent = true; private boolean firstRefresh = true; private volatile boolean acquireWatching = false; + private volatile int acquireWatchMode = -1; // mode we optimistically started private MaterialToolbar toolbar; private RecyclerView recyclerView; private TextView tvEmpty; @@ -195,55 +189,19 @@ private void refresh() { } runOnPool(() -> { boolean dark = HugePageColor.isDark(this); - // KO attribution availability comes from the cached capability probe - // (re-probed at most every few seconds, with a retry on a negative), - // so a single flaky root `test -e` can't flap the whole view between - // KO and scan every tick. Absent -> force scan, disable the KO entry. - // A present-but-empty pool (no VM) keeps KO available ("no VM"). - boolean koNow = model.caps(false).koAttribution; - boolean useScan = scanMode || !koNow; - - List list = new ArrayList<>(); - String emptyText; - if (useScan) { - // Scan mode: system-wide THP (Anon + Shmem), no module needed. - try { - list = scanProc(dark); - } catch (Exception e) { - Log.w(TAG, "Scan failed", e); - } - emptyText = getString(R.string.hugepage_proc_scan_empty); - } else { - // Reconcile the served-page table on every query, then read the - // reconciled per-owner live page counts. - var livePages = new HashMap(); - try { - run("echo 1 > %s/reconcile", SYSFS_PARAMS); - var sum = shellReadFile(pathJoin(SYSFS_PARAMS, "served_summary")); - for (var ln : sum.split("\n")) { - ln = ln.trim(); - if (!ln.startsWith("owner ")) continue; - int pid = -1; - long pages = 0; - for (var tok : ln.split("\\s+")) { - if (tok.startsWith("pid=")) pid = Integer.parseInt(tok.substring(4)); - else if (tok.startsWith("pages=")) pages = Long.parseLong(tok.substring(6)); - } - if (pid > 0) livePages.put(pid, pages); - } - } catch (Exception e) { - Log.w(TAG, "reconcile/served_summary unavailable (old .ko?)", e); - } - try { - var owners = parseOwners(shellReadFile(SYSFS_OWNERS)); - if (!owners.isEmpty()) list = buildFromOwners(owners, dark, livePages); - } catch (Exception e) { - Log.w(TAG, "Failed to collect hugepage owners", e); - } - // Reached only when the KO API is present (else we auto-switched - // to scan above), so an empty list genuinely means "no VM". - emptyText = getString(R.string.hugepage_proc_owner_empty); - } + // Pull the usage list through the degradation ladder: honour the user's + // scan preference by pinning SCAN, else auto (KO, degrading to a THP scan + // when the module's attribution knobs are absent). The winning impl says + // what's actually shown; koAvailable() says whether KO *could* be used, + // to keep the mode menu + toast meaningful even while showing a scan. + var usage = model.usage(scanMode ? HugePageModel.Source.SCAN : null); + boolean koNow = model.koAvailable(); + boolean useScan = usage.source != HugePageModel.Source.KO; + + List list = buildFromEntries(usage.entries, dark); + String emptyText = getString(useScan + ? R.string.hugepage_proc_scan_empty + : R.string.hugepage_proc_owner_empty); // Three-value model: hold (pages in pool) + traced (sum of all // applied/served processes) + waiting-to-acquire = want. @@ -255,16 +213,11 @@ private void refresh() { // orphaned (served-but-owner-gone) pages alike. long tracedKb = 0; for (var r : list) if (r.thpKb > 0) tracedKb += r.thpKb; - long holdKb, wantKb; - var pool = model.readPoolPages(); - boolean kernelAcquiring = pool != null && pool.length >= 4 && pool[3] != 0; - if (pool != null) { - holdKb = pool[1] * 2048; // avail - wantKb = pool[2] * 2048; // want - } else { - holdKb = 0; - wantKb = tracedKb; - } + var snap = model.state(); + boolean kernelAcquiring = snap.acquiring; + int kernelMode = snap.acquireMode; + long holdKb = snap.loaded ? snap.free * 2048 : 0; // avail + long wantKb = snap.loaded ? snap.targetIdeal * 2048 : tracedKb; // want long deficitKb = wantKb - holdKb - tracedKb; if (deficitKb > 0) { list.add(new HugePageProcess( @@ -279,6 +232,7 @@ private void refresh() { var fHold = holdKb; var fWant = wantKb; var fAcquiring = kernelAcquiring; + var fMode = kernelMode; runOnUiThread(() -> { koPresent = fKoNow; // Toast only on the present->absent edge while the user wants KO, @@ -292,114 +246,33 @@ private void refresh() { // (right after inflateMenu, before the menu is laid out) doesn't // stick, leaving the radio showing the wrong mode. updateModeChecked(); - adapter.setAcquiring(fAcquiring || acquireWatching); + int uiMode = fAcquiring ? fMode + : (acquireWatching ? acquireWatchMode : -1); + adapter.setAcquireState(fAcquiring || acquireWatching, uiMode); showResult(finalList, fTraced, fHold, fWant, finalEmpty); }); }); } + /** Map ladder usage entries to display rows (color + VM-name enrichment). */ @NonNull - private List buildFromOwners( - @NonNull List owners, boolean dark, - @NonNull Map livePages + private List buildFromEntries( + @NonNull List entries, boolean dark ) { - var pids = new ArrayList(); - for (var o : owners) pids.add(o.pid); - var procInfo = model.readProcInfo(pids); - - // Best-effort map pid -> VM name (only running VMs have a live pid). - var vmMap = model.vmNames(false); - - var result = new ArrayList(); - for (var o : owners) { - var info = procInfo.get(o.pid); - char state = info != null ? info.state : '?'; - boolean alive = info != null && info.alive; - // Occupancy = traced (reconciled served-page count for this owner), - // not the /proc THP scan. /proc is only used for state + liveness. - Long served = livePages.get(o.pid); - long tracedKb = (served != null ? served : 0L) * 2048; - result.add(new HugePageProcess( - o.pid, o.comm, -1, tracedKb, state, - vmMap.get(o.pid), alive, HugePageColor.forPid(o.pid, dark), - false, false)); - } - result.sort((a, b) -> Long.compare(b.thpKb, a.thpKb)); - return result; - } - - /** - * VM-only "scan": list the daemon's running VM processes and their actual - * THP occupancy (AnonHugePages + ShmemPmdMapped) from smaps. We deliberately - * do NOT scan all of /proc - system-wide THP (launchers, etc.) is noise that - * has nothing to do with the reserve pool. Works without the kernel module - * (ground-truth occupancy straight from each VM's smaps). - */ - @NonNull - private List scanProc(boolean dark) { - // Running VMs (pid -> name) come from the daemon. + // Best-effort pid -> VM name (running VMs only); for KO rows this labels + // the process, for scan rows it echoes the name the entry already carries. var vmMap = model.vmNames(false); - if (vmMap.isEmpty()) return new ArrayList<>(); - - var procInfo = model.readProcInfo(vmMap.keySet()); - var result = new ArrayList(); - for (var e : vmMap.entrySet()) { - int pid = e.getKey(); - var info = procInfo.get(pid); - char state = info != null ? info.state : '?'; - long thp = info != null ? info.thpKb : -1; - boolean alive = info != null && info.alive; + for (var e : entries) { result.add(new HugePageProcess( - pid, e.getValue(), -1, thp, state, e.getValue(), alive, - HugePageColor.forPid(pid, dark), false, false)); + e.pid, e.comm, -1, e.pages * 2048, e.state, + vmMap.getOrDefault(e.pid, e.comm), e.alive, + HugePageColor.forPid(e.pid, dark), false, false)); } result.sort((a, b) -> Long.compare(b.thpKb, a.thpKb)); return result; } - private static final class Owner { - final int pid; - final String comm; - final long served; - - Owner(int pid, String comm, long served) { - this.pid = pid; - this.comm = comm; - this.served = served; - } - } - - @NonNull - private List parseOwners(@NonNull String raw) { - var list = new ArrayList(); - for (var line : raw.split("\n")) { - line = line.trim(); - if (line.isEmpty()) continue; - // Format: pid= served= comm= - int pid = -1; - long served = 0; - String comm = "?"; - int commIdx = line.indexOf("comm="); - if (commIdx >= 0) comm = line.substring(commIdx + 5).trim(); - for (var tok : line.split("\\s+")) { - if (tok.startsWith("pid=")) { - try { - pid = Integer.parseInt(tok.substring(4)); - } catch (NumberFormatException ignored) { - } - } else if (tok.startsWith("served=")) { - try { - served = Long.parseLong(tok.substring(7)); - } catch (NumberFormatException ignored) { - } - } - } - if (pid > 0) list.add(new Owner(pid, comm, served)); - } - return list; - } - private void showResult( @NonNull List list, long usedKb, long availKb, long wantKb, @NonNull String emptyText @@ -491,64 +364,62 @@ private void doKill(@NonNull HugePageProcess proc, int signal) { } @Override - public void onAcquire() { + public void onAcquire(int mode) { if (acquireWatching) return; // already polling a run Toast.makeText(this, R.string.hugepage_proc_acquire_running, LENGTH_SHORT).show(); acquireWatching = true; - adapter.setAcquiring(true); // immediate spinner feedback + acquireWatchMode = mode; // remember which we just started + adapter.setAcquireState(true, mode); // immediate spinner feedback // Fire-and-poll on a dedicated thread so the shared pool executor (and // thus the periodic refresh that animates the climbing numbers) is never // blocked. The kernel 'acquire' write returns immediately; the worker // migrates in the background and we watch acquire_active for completion. new Thread(() -> { - var caps = model.caps(false); - boolean v7 = caps.hasAcquire; - boolean triggered = false; - try { - if (v7) { - triggered = run("echo 1 > %s/acquire", SYSFS_PARAMS).isSuccess(); - } else if (caps.hasManualRefill) { - triggered = run("echo 1 > %s/manual_refill", SYSFS_PARAMS).isSuccess(); - } - } catch (Exception e) { - Log.w(TAG, "acquire trigger failed", e); - } + // Fill the pool via the acquire ladder: the migrating 'acquire' knob, + // degrading to 'manual_refill' on any failure (e.g. -ENOSYS on kernels + // that can't migrate). The trace's last entry says what actually ran. + var res = model.acquire(mode); + boolean triggered = res.ok(); + // Only a migrating acquire mode exposes acquire_active to poll; the + // manual_refill fallback is fire-and-forget with no progress to watch. + boolean migrating = triggered && !"manual_refill".equals(res.impl); + final boolean degraded = res.degraded; // ran below the requested mode + final String actualImpl = res.impl != null ? res.impl : ""; if (!triggered) { acquireWatching = false; runOnUiThread(() -> Toast.makeText(this, R.string.hugepage_refill_failed, LENGTH_SHORT).show()); return; } - if (!v7) { - // v6 (manual_refill): an async refill with no acquire_active to - // poll and no served=/pool_want to report a precise got/want. - // Just trigger it and let the periodic refresh animate the bar. + if (!migrating) { + // Fell all the way to manual_refill: async, no acquire_active/served + // to poll. Name the fallback (it is itself the degradation). acquireWatching = false; runOnUiThread(() -> { - Toast.makeText(this, R.string.hugepage_proc_acquire_done, - LENGTH_SHORT).show(); + Toast.makeText(this, + getString(R.string.hugepage_acquire_degraded, actualImpl), + LENGTH_LONG).show(); refresh(); }); return; } - // v7: poll until the worker clears acquire_active. Each tick nudges + // Migrating: poll until the worker clears acquire_active. Each tick nudges // the UI to re-read refill_stat so the count visibly climbs. - long[] pages = null; - for (int i = 0; i < ACQUIRE_POLL_MAX; i++) { + boolean running = true; + for (int i = 0; i < ACQUIRE_POLL_MAX && running; i++) { try { Thread.sleep(ACQUIRE_POLL_MS); } catch (InterruptedException ignored) {} - pages = model.readPoolPages(); // {total, avail, want, acquiring, served} - runOnUiThread(this::refresh); // animate climbing numbers - if (pages == null || pages.length < 5 || pages[3] == 0) break; + runOnUiThread(this::refresh); // animate climbing numbers (reads full state) + running = model.acquiring(); // cheap acquire_active-only probe } - var finalPages = pages; + var finalSnap = model.state(); // one full read for the final got/want acquireWatching = false; runOnUiThread(() -> { - if (finalPages != null && finalPages.length >= 5) { + if (finalSnap != null && finalSnap.loaded) { // Achieved = owned + traced (pool_avail + served), NOT // pool_total: after a shrink that left served pages out, // pool_total under-counts the pages VMs already hold. - long got = finalPages[1] + finalPages[4]; - long want = finalPages[2] >= 0 ? finalPages[2] : got; + long got = finalSnap.free + finalSnap.lent; + long want = finalSnap.targetIdeal; String msg = got >= want ? getString(R.string.hugepage_proc_acquire_full, fmtPages(want)) @@ -559,20 +430,26 @@ public void onAcquire() { Toast.makeText(this, R.string.hugepage_proc_acquire_done, LENGTH_SHORT).show(); } + // Requested a higher mode than could run (e.g. v3 -> v2) -> name it. + if (degraded) Toast.makeText(this, + getString(R.string.hugepage_acquire_degraded, actualImpl), + LENGTH_LONG).show(); refresh(); }); }, "hugepage-acquire").start(); } + /** Long-press an acquire button: explain the mode, with a Run/Cancel choice. */ + @Override + public void onAcquireInfo(int mode) { + HugePageActivity.showAcquireInfo(this, mode, () -> onAcquire(mode)); + } + /** Tapping the acquire spinner stops the run; the poll loop then finishes. */ @Override public void onStopAcquire() { runOnPool(() -> { - try { - run("echo 0 > %s/acquire", SYSFS_PARAMS); - } catch (Exception e) { - Log.w(TAG, "stop acquire failed", e); - } + model.stopAcquire(); runOnUiThread(this::refresh); }); } 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 88aee65..1f41f3f 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 @@ -32,7 +32,12 @@ public interface Listener { void onShowStack(@NonNull HugePageProcess proc); - void onAcquire(); + /** @param mode acquire algorithm: 2 = migrate + system reclaim, + * 3 = migrate + per-block evict to zram. */ + void onAcquire(int mode); + + /** Long-press: show the what-does-this-mode-do dialog (with a Run choice). */ + void onAcquireInfo(int mode); void onStopAcquire(); } @@ -41,16 +46,23 @@ public interface Listener { private final List items = new ArrayList<>(); private final Listener listener; private boolean acquiring = false; + private int acquireMode = -1; // running v (1/2/3); -1 = unknown (old module) public HugePageProcessAdapter(@NonNull Listener listener) { this.listener = listener; setHasStableIds(true); } - /** Toggle the spinner on the Acquire row while a run is in flight. */ - public void setAcquiring(boolean acquiring) { - if (this.acquiring == acquiring) return; + /** + * Reflect the acquire state on the row: idle = three buttons, running with a + * known mode = that slot spins + the other two greyed out, running with an + * unknown mode (-1, old module) = all three spin. + */ + public void setAcquireState(boolean acquiring, int mode) { + if (!acquiring) mode = -1; + if (this.acquiring == acquiring && this.acquireMode == mode) return; this.acquiring = acquiring; + this.acquireMode = mode; notifyDataSetChanged(); } @@ -104,29 +116,19 @@ public void onBindViewHolder(@NonNull Holder holder, int position) { holder.btnStack.setVisibility(GONE); holder.btnStack.setOnClickListener(null); holder.itemView.setAlpha(1f); + holder.btnKill.setVisibility(GONE); + holder.btnKill.setOnClickListener(null); if (p.acquire) { - // While a run is in flight show the spinner in place of the - // button - same look as before, but tapping it stops the acquire. - holder.progressAcquire.setVisibility(acquiring ? VISIBLE : GONE); - holder.progressAcquire.setOnClickListener( - acquiring ? v -> listener.onStopAcquire() : null); - holder.btnKill.setVisibility(acquiring ? GONE : VISIBLE); - holder.btnKill.setImageResource(R.drawable.ic_download); - holder.btnKill.setColorFilter(MaterialColors.getColor( - holder.itemView, androidx.appcompat.R.attr.colorPrimary)); - holder.btnKill.setContentDescription( - ctx.getString(R.string.hugepage_proc_acquire)); - holder.btnKill.setOnClickListener(v -> listener.onAcquire()); + bindAcquireSlot(holder.btnAcquireV1, holder.progressAcquireV1, 1); + bindAcquireSlot(holder.btnAcquireV2, holder.progressAcquireV2, 2); + bindAcquireSlot(holder.btnAcquireV3, holder.progressAcquireV3, 3); } else { - holder.progressAcquire.setVisibility(GONE); - holder.progressAcquire.setOnClickListener(null); - holder.btnKill.setVisibility(GONE); - holder.btnKill.setOnClickListener(null); + hideAcquireSlots(holder); } return; } - holder.progressAcquire.setVisibility(GONE); + hideAcquireSlots(holder); holder.btnKill.setVisibility(VISIBLE); // Restore the kill affordance (views are recycled from acquire rows). holder.btnKill.setImageResource(R.drawable.ic_close); @@ -173,6 +175,34 @@ public void onBindViewHolder(@NonNull Holder holder, int position) { holder.btnKill.setOnClickListener(dead ? null : v -> listener.onKill(p)); } + /** One acquire slot: spins iff running AND (mode unknown or this mode); when a + * run is in flight the non-running slots are visible-but-disabled buttons. */ + private void bindAcquireSlot(View btn, View spinner, int mode) { + boolean spin = acquiring && (acquireMode < 0 || acquireMode == mode); + btn.setVisibility(spin ? GONE : VISIBLE); + // Left enabled so long-press always opens the info dialog; short-press is + // gated on !acquiring and the icon greys via alpha while a run is in flight. + btn.setAlpha(acquiring ? 0.38f : 1f); + btn.setOnClickListener(v -> { if (!acquiring) listener.onAcquire(mode); }); + btn.setOnLongClickListener(v -> { listener.onAcquireInfo(mode); return true; }); + spinner.setVisibility(spin ? VISIBLE : GONE); + spinner.setOnClickListener(spin ? v -> listener.onStopAcquire() : null); + } + + /** Hide all six acquire views and reset the buttons' enabled state (recycled). */ + private static void hideAcquireSlots(Holder h) { + View[] all = {h.btnAcquireV1, h.btnAcquireV2, h.btnAcquireV3, + h.progressAcquireV1, h.progressAcquireV2, h.progressAcquireV3}; + for (View v : all) { + v.setVisibility(GONE); + v.setOnClickListener(null); + } + for (View b : new View[]{h.btnAcquireV1, h.btnAcquireV2, h.btnAcquireV3}) { + b.setOnLongClickListener(null); + b.setAlpha(1f); + } + } + static final class Holder extends RecyclerView.ViewHolder { final ImageView icon; final TextView title; @@ -181,7 +211,12 @@ static final class Holder extends RecyclerView.ViewHolder { final TextView stateView; final ImageButton btnStack; final ImageButton btnKill; - final View progressAcquire; + final View btnAcquireV1; + final View btnAcquireV2; + final View btnAcquireV3; + final View progressAcquireV1; + final View progressAcquireV2; + final View progressAcquireV3; Holder(@NonNull View v) { super(v); @@ -192,7 +227,12 @@ static final class Holder extends RecyclerView.ViewHolder { stateView = v.findViewById(R.id.tv_proc_state); btnStack = v.findViewById(R.id.btn_proc_stack); btnKill = v.findViewById(R.id.btn_proc_kill); - progressAcquire = v.findViewById(R.id.progress_proc_acquire); + btnAcquireV1 = v.findViewById(R.id.btn_acquire_v1); + btnAcquireV2 = v.findViewById(R.id.btn_acquire_v2); + btnAcquireV3 = v.findViewById(R.id.btn_acquire_v3); + progressAcquireV1 = v.findViewById(R.id.progress_acquire_v1); + progressAcquireV2 = v.findViewById(R.id.progress_acquire_v2); + progressAcquireV3 = v.findViewById(R.id.progress_acquire_v3); } } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java new file mode 100644 index 0000000..84df940 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java @@ -0,0 +1,48 @@ +package cn.classfun.droidvm.ui.hugepage; + +import androidx.annotation.Nullable; + +/** + * One implementation's attempt within a degradation ladder: which implementation + * ran, plus its return value on success or its failure reason otherwise. The pair + * {@code (value | error, impl)} is what a {@link HugePageModel} feature call + * accumulates into an ordered {@code List>}; a success stops the ladder, + * so the caller judges the overall outcome from the last element: + *

    + *
  • last {@link #ok()} and alone — success via the primary implementation;
  • + *
  • last {@link #ok()} after earlier fails — success, degraded to a fallback;
  • + *
  • last not {@link #ok()} — failure, and every rung's reason is in the list.
  • + *
+ * + * @param the feature's implementation enum (which way it was done) + * @param the value produced on success ({@link Void} for pure actions) + */ +final class Try { + /** The implementation this attempt used. */ + final I impl; + /** The value produced on success; {@code null} on failure (or for {@link Void}). */ + @Nullable + final V value; + /** The failure reason; {@code null} on success. */ + @Nullable + final String error; + + private Try(I impl, @Nullable V value, @Nullable String error) { + this.impl = impl; + this.value = value; + this.error = error; + } + + static Try ok(I impl, @Nullable V value) { + return new Try<>(impl, value, null); + } + + static Try fail(I impl, String error) { + return new Try<>(impl, null, error); + } + + /** True if this implementation succeeded (a non-null error means failure). */ + boolean ok() { + return error == null; + } +} diff --git a/app/src/main/res/layout/acquire_buttons.xml b/app/src/main/res/layout/acquire_buttons.xml new file mode 100644 index 0000000..fb26d47 --- /dev/null +++ b/app/src/main/res/layout/acquire_buttons.xml @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_hugepage.xml b/app/src/main/res/layout/activity_hugepage.xml index 7f25101..44b5712 100644 --- a/app/src/main/res/layout/activity_hugepage.xml +++ b/app/src/main/res/layout/activity_hugepage.xml @@ -121,7 +121,7 @@ android:icon="@drawable/ic_resize" android:inputType="number" app:ti_max="16GiB" - app:ti_min="256MiB" + app:ti_min="0MiB" app:ti_mode="size" app:ti_precision="2MiB" /> @@ -260,6 +260,37 @@ android:text="@string/hugepage_view_processes" app:icon="@drawable/ic_file_tree" /> + + + + + + + + + + + + + - - - - - - - - diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 0fed84d..ca74410 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -964,6 +964,18 @@ 池大小已保存并应用 获取 获取完成 + 获取大页 + 执行 + 已降级,实际使用 %1$s + v1 + v2 + v3 + 获取 v1(alloc_contig_pages 扫描) + 获取 v2(迁移+系统回收) + 获取 v3(迁移+逐块挤到 zram) + 用 alloc_contig_pages 收集大页 + 用 try_to_free_mem_cgroup_pages 把其他程序挤到 swap/zram\n再用 alloc_contig_range 收集大页 + 用 folio_isolate_lru + reclaim_pages 精准把页面挤到 swap/zram\n再用 alloc_contig_range 收集大页 停止获取 获取完成:已达成 %1$s 获取结束:达成 %1$s / 目标 %2$s(系统目前无法再凑出更多) diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index ca90d62..63c8f8c 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -922,11 +922,23 @@ 獲取 獲取大頁中… 獲取完成 + v1 + v2 + v3 + 獲取 v1(alloc_contig_pages 掃描) + 獲取 v2(遷移+系統回收) + 獲取 v3(遷移+逐塊擠到 zram) + 已降級,實際使用 %1$s + 執行 + 用 alloc_contig_pages 蒐集大頁 + 用 try_to_free_mem_cgroup_pages 把其他程式擠到 swap/zram\n再用 alloc_contig_range 蒐集大頁 + 用 folio_isolate_lru + reclaim_pages 精準把頁面擠到 swap/zram\n再用 alloc_contig_range 蒐集大頁 停止獲取 獲取完成:已達成 %1$s 獲取結束:達成 %1$s / 目標 %2$s(系統目前無法再湊出更多) 模組不足,嘗試 userspace 協助… 觸發獲取失敗 + 獲取大頁 池大小 池大小已儲存(重啟後生效) 池大小已儲存並套用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7692a34..1ab0997 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -962,12 +962,24 @@ Failed to trigger acquire Save Acquire + v1 + v2 + v3 + Acquire v1 (alloc_contig_pages scan) + Acquire v2 (migrate + system reclaim) + Acquire v3 (migrate + per-block evict to zram) + Run + Uses alloc_contig_pages to collect huge pages + Uses try_to_free_mem_cgroup_pages to push other apps into swap/zram\nthen alloc_contig_range to collect huge pages + Uses folio_isolate_lru + reclaim_pages to precisely push pages into swap/zram\nthen alloc_contig_range to collect huge pages Acquiring huge pages… Acquire done + Downgraded — ran %1$s instead Stop acquiring Acquire complete: reached %1$s Acquire stopped: got %1$s of %2$s (system can\'t migrate more right now) Module short; trying userspace assist… + Acquire huge pages Pool Size Pool size saved (effective after reboot) Pool size saved & applied From 3274b4d208199164e1aeb52f26d74066706e9569 Mon Sep 17 00:00:00 2001 From: HuJK Date: Sun, 5 Jul 2026 10:22:43 +0800 Subject: [PATCH 02/10] prebuilts: bump submodule to 2c31cec Point the prebuilts submodule at 2c31cecdedb5110e8861b8b698ba5b538725cfb7 (same DroidVM-Prebuilt-Root 4016dba, rebuilt). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TaHcvnHaZk8qqpKiS25Hbk --- app/src/main/assets/prebuilts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/assets/prebuilts b/app/src/main/assets/prebuilts index fc972f1..2c31cec 160000 --- a/app/src/main/assets/prebuilts +++ b/app/src/main/assets/prebuilts @@ -1 +1 @@ -Subproject commit fc972f1871a2accb31fc0d5f2c08cc8a37d8dd8a +Subproject commit 2c31cecdedb5110e8861b8b698ba5b538725cfb7 From ca0a7f0498a71a79c29787f35a4ec718d740dc06 Mon Sep 17 00:00:00 2001 From: HuJK Date: Sun, 5 Jul 2026 10:22:43 +0800 Subject: [PATCH 03/10] (fix) fullscreen mode doesn't work correctly --- .../display/vnc/display/VMVncDisplayActivity.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 1961f78..e177de7 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 @@ -346,6 +346,15 @@ private boolean onMouseTouch(View v, MotionEvent event) { 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; @@ -626,7 +635,9 @@ private void toggleFullscreen() { var controller = getWindow().getInsetsController(); if (controller == null) return; if (isFullscreen) { - hideBars(); + 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); From 9bedddd6846129d9ec8940a2ce1e9f0c04e1bc25 Mon Sep 17 00:00:00 2001 From: HuJK Date: Sun, 5 Jul 2026 10:39:09 +0800 Subject: [PATCH 04/10] vm: route list auto-open-after-start through the same console picker as VM info Starting a VM from the list auto-opened VMConsoleActivity (UART) unconditionally (MainVMFragment.onVMStateChanged hard-coded it), while the VM-info console button (ConsoleButton.openDefaultConsole) is display-aware: native display, else VNC, else the serial console. So a VNC/native VM started from the list wrongly popped the UART console instead of its display. Extract that routing into a shared VmConsoleRouter.openDefault(); the info button and the list auto-open now both call it, so they agree. ConsoleButton's chooser open-helpers delegate to the router too (single source of truth). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01TaHcvnHaZk8qqpKiS25Hbk --- .../droidvm/ui/main/vm/MainVMFragment.java | 9 +- .../ui/vm/console/VmConsoleRouter.java | 104 ++++++++++++++++++ .../droidvm/ui/vm/info/ConsoleButton.java | 69 ++---------- 3 files changed, 119 insertions(+), 63 deletions(-) create mode 100644 app/src/main/java/cn/classfun/droidvm/ui/vm/console/VmConsoleRouter.java 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 53c084c..442b18a 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 @@ -28,7 +28,7 @@ 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.console.VMConsoleActivity; +import cn.classfun.droidvm.ui.vm.console.VmConsoleRouter; import cn.classfun.droidvm.ui.vm.edit.VMEditActivity; import cn.classfun.droidvm.ui.vm.info.VMInfoActivity; @@ -133,10 +133,9 @@ public void onVMStateChanged(UUID vmId, VMState state) { if (state == VMState.RUNNING && wantOpenConsole.compareAndSet(true, false)) { var config = adapter.items.findById(vmId); if (config == null) return; - var intent = new Intent(requireContext(), VMConsoleActivity.class); - intent.putExtra(VMConsoleActivity.EXTRA_VM_ID, vmId.toString()); - intent.putExtra(VMConsoleActivity.EXTRA_VM_NAME, config.getName()); - startActivity(intent); + // Same routing as the VM-info console button (native -> VNC -> + // serial), so a VNC/native VM auto-opens its display, not always UART. + VmConsoleRouter.openDefault(requireContext(), vmId, config, true); } }); } 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 new file mode 100644 index 0000000..923f7de --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/console/VmConsoleRouter.java @@ -0,0 +1,104 @@ +package cn.classfun.droidvm.ui.vm.console; + +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; + +import java.util.ArrayList; +import java.util.UUID; + +import cn.classfun.droidvm.lib.daemon.DaemonConnection; +import cn.classfun.droidvm.lib.store.vm.VMConfig; +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 + * this in one place is why both paths agree instead of the list always opening + * the UART console regardless of the VM's display. + */ +public final class VmConsoleRouter { + private VmConsoleRouter() { + } + + /** + * Open the VM's default view. {@code running} = the VM is up (so the serial + * console shows the live stream, not saved logs). + */ + 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); + return; + } + // Serial console: ask the daemon which streams exist, prefer uart then stdio. + DaemonConnection.getInstance().buildRequest("vm_console_list") + .put("vm_id", vmId.toString()) + .onResponse(resp -> { + var data = resp.optJSONArray("data"); + String stream = null; + if (data != null) { + var names = new ArrayList(); + for (int i = 0; i < data.length(); i++) { + var n = data.optString(i, ""); + if (!n.isEmpty()) names.add(n); + } + if (names.contains("uart")) stream = "uart"; + else if (names.contains("stdio")) stream = "stdio"; + } + if (stream == null) return; + final var s = stream; + new Handler(Looper.getMainLooper()).post( + () -> openConsole(ctx, vmId, config, s, !running)); + }) + .onUnsuccessful(r -> { + }) + .invoke(); + } + + public static void openConsole(@NonNull Context ctx, @NonNull UUID vmId, + @NonNull VMConfig config, @NonNull String stream, boolean logs) { + var intent = new Intent(ctx, VMConsoleActivity.class); + intent.putExtra(VMConsoleActivity.EXTRA_VM_ID, vmId.toString()); + intent.putExtra(VMConsoleActivity.EXTRA_VM_NAME, config.getName()); + intent.putExtra(VMConsoleActivity.EXTRA_STREAM, stream); + intent.putExtra(VMConsoleActivity.EXTRA_LOGS, logs); + ctx.startActivity(intent); + } + + public static void openNative(@NonNull Context ctx, @NonNull UUID vmId, @NonNull VMConfig config) { + 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)); + 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); + } + + public static void openVncExt(@NonNull Context ctx, @NonNull UUID vmId, @NonNull VMConfig config) { + var intent = new Intent(ctx, VMVncPresentationActivity.class); + intent.putExtra(BaseVncActivity.EXTRA_VM_ID, vmId.toString()); + intent.putExtra(BaseVncActivity.EXTRA_VM_NAME, config.getName()); + ctx.startActivity(intent); + } +} diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/info/ConsoleButton.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/info/ConsoleButton.java index 14f4602..70597d3 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/info/ConsoleButton.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/info/ConsoleButton.java @@ -3,7 +3,6 @@ import static android.widget.Toast.LENGTH_SHORT; import android.content.DialogInterface; -import android.content.Intent; import android.os.Handler; import android.os.Looper; import android.util.Log; @@ -23,11 +22,7 @@ import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.lib.store.vm.VMState; import cn.classfun.droidvm.lib.ui.IconItemAdapter; -import cn.classfun.droidvm.ui.vm.console.VMConsoleActivity; -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; +import cn.classfun.droidvm.ui.vm.console.VmConsoleRouter; public final class ConsoleButton { private static final String TAG = "VMInfoActivity"; @@ -54,37 +49,11 @@ void showConsoleChooser() { } void openDefaultConsole() { - if (parent.config != null - && parent.config.item.optBoolean("native_display_enabled", false)) { - openNativeDisplay(); - return; - } - if (parent.config != null && parent.config.item.optBoolean("vnc_enabled", false)) { - openVncDisplay(); - return; - } - DaemonConnection.OnResponse res = resp -> { - var data = resp.optJSONArray("data"); - if (data == null) return; - var list = new ArrayList(); - for (int i = 0; i < data.length(); i++) { - var name = data.optString(i, ""); - if (!name.isEmpty()) list.add(name); - } - String defaultConsole = null; - if (list.contains("uart")) - defaultConsole = "uart"; - if (defaultConsole == null && list.contains("stdio")) - defaultConsole = "stdio"; - if (defaultConsole == null) return; - final var target = defaultConsole; - mainHandler.post(() -> openConsole(target)); - }; - DaemonConnection.getInstance().buildRequest("vm_console_list") - .put("vm_id", parent.vmId.toString()) - .onResponse(res) - .onUnsuccessful(r -> {}) - .invoke(); + if (parent.config == null) return; + // Shared with the VM-list auto-open-after-start (VmConsoleRouter), so both + // pick the same view: native -> VNC -> serial (uart, then stdio). + VmConsoleRouter.openDefault(parent, parent.vmId, parent.config, + parent.currentState != VMState.STOPPED); } private void buildConsoleChooserDialog(@Nullable JSONArray streams) { @@ -144,38 +113,22 @@ private void buildConsoleChooserDialog(@Nullable JSONArray streams) { } private void openConsole(@NonNull String stream) { - var intent = new Intent(parent, VMConsoleActivity.class); - intent.putExtra(VMConsoleActivity.EXTRA_VM_ID, parent.vmId.toString()); - intent.putExtra(VMConsoleActivity.EXTRA_VM_NAME, parent.config.getName()); - intent.putExtra(VMConsoleActivity.EXTRA_STREAM, stream); - intent.putExtra(VMConsoleActivity.EXTRA_LOGS, parent.currentState == VMState.STOPPED); - parent.startActivity(intent); + VmConsoleRouter.openConsole(parent, parent.vmId, parent.config, stream, + parent.currentState == VMState.STOPPED); } private void openNativeDisplay() { if (parent.config == null) return; - var item = parent.config.item; - var intent = new Intent(parent, VMNativeDisplayActivity.class); - intent.putExtra(VMNativeDisplayActivity.EXTRA_VM_ID, parent.vmId.toString()); - intent.putExtra(VMNativeDisplayActivity.EXTRA_VM_NAME, parent.config.getName()); - intent.putExtra(VMNativeDisplayActivity.EXTRA_WIDTH, item.optLong("display_width", 1280)); - intent.putExtra(VMNativeDisplayActivity.EXTRA_HEIGHT, item.optLong("display_height", 720)); - parent.startActivity(intent); + VmConsoleRouter.openNative(parent, parent.vmId, parent.config); } private void openVncDisplay() { if (parent.config == null) return; - var intent = new Intent(parent, VMVncDisplayActivity.class); - intent.putExtra(BaseVncActivity.EXTRA_VM_ID, parent.vmId.toString()); - intent.putExtra(BaseVncActivity.EXTRA_VM_NAME, parent.config.getName()); - parent.startActivity(intent); + VmConsoleRouter.openVnc(parent, parent.vmId, parent.config); } private void openVncExtDisplay() { if (parent.config == null) return; - var intent = new Intent(parent, VMVncPresentationActivity.class); - intent.putExtra(BaseVncActivity.EXTRA_VM_ID, parent.vmId.toString()); - intent.putExtra(BaseVncActivity.EXTRA_VM_NAME, parent.config.getName()); - parent.startActivity(intent); + VmConsoleRouter.openVncExt(parent, parent.vmId, parent.config); } } From 8f83768a2fa703cd63125158677a6b93eb9044c4 Mon Sep 17 00:00:00 2001 From: HuJK Date: Sun, 5 Jul 2026 22:20:43 +0800 Subject: [PATCH 05/10] network: watchdog re-asserts IP forwarding while an L3 bridge runs netd owns net.ipv4.ip_forward and net.ipv6.conf.all.forwarding globally and clears them whenever it retunes tethering, silently black-holing every forwarded guest packet. IptablesBackend enables them once at init; DefaultRouterWatcher now re-asserts them on its 5s tick for as long as a running LINUX + L3 network needs kernel forwarding. Gated on hasL3LinuxBridge() -- the same condition the routing rules use -- so with no L3 bridge netd's value is left alone rather than forcing global forwarding on the phone. Both families are covered because netd flips v4 and v6 together. Reads before writing via runQuiet so the poll stays out of logcat; only a value netd actually reset is corrected and logged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TaHcvnHaZk8qqpKiS25Hbk --- .../network/backend/DefaultRouterWatcher.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) 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 b17f26b..ac7a6e0 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 @@ -24,6 +24,7 @@ import cn.classfun.droidvm.lib.store.network.BridgeType; import cn.classfun.droidvm.lib.store.network.NetworkState; import cn.classfun.droidvm.lib.store.network.UplinkMode; +import cn.classfun.droidvm.lib.utils.RunUtils; public final class DefaultRouterWatcher { private static final String TAG = "DefaultRouterWatcher"; @@ -253,6 +254,63 @@ private synchronized void runOnce() { ensureHostIpMonitor(); updateHostIps(); updateDefaultRouter(); + reassertForwarding(); + } + + /** + * Re-asserts the kernel forwarding sysctls that guest L3 routing depends + * on. netd owns these globally and clears them whenever it retunes + * tethering, which silently black-holes every forwarded guest packet. + * {@link cn.classfun.droidvm.daemon.network.backend.iptables.IptablesBackend} + * enables them once at init; this keeps them enabled for as long as a + * Linux-bridge L3 network is running. + * + *

Gated on {@link #hasL3LinuxBridge()} -- the same "running LINUX + L3" + * condition the routing rules use. With no such network there is nothing + * to forward, so netd's value is left alone rather than forcing global + * forwarding on the phone. Both families are covered because netd flips + * v4 and v6 together (as does {@code enableIpForward}). Reads before + * writing: an untouched value costs one cheap cat and stays silent; only a + * value netd actually reset is corrected and logged (it means netd raced + * us). Never throws out of the tick -- a transient shell failure must not + * tear down the routing rules. + */ + private void reassertForwarding() { + try { + if (!hasL3LinuxBridge()) return; + ensureSysctlOne("/proc/sys/net/ipv4/ip_forward", "net.ipv4.ip_forward"); + ensureSysctlOne("/proc/sys/net/ipv6/conf/all/forwarding", + "net.ipv6.conf.all.forwarding"); + } catch (Exception e) { + Log.w(TAG, "Failed to re-assert forwarding sysctls", e); + } + } + + /** + * Writes 1 to {@code path} only if it is not already 1; logs corrections. + * Both the read and the write go through {@code runQuiet} so the 5s poll + * stays out of logcat -- {@code RunContext.run} would log a "Running + * command: cat ..." line every tick. The only line this ever emits is the + * warning below, and only when netd actually reset the value. + */ + private void ensureSysctlOne(@NonNull String path, @NonNull String name) { + var cur = RunUtils.runQuiet("cat " + path).getOutString(); + if ("1".equals(cur)) return; + Log.w(TAG, fmt("%s=%s (netd reset it); re-enabling forwarding", + name, cur.isEmpty() ? "?" : cur)); + RunUtils.runQuiet("echo 1 > " + path); + } + + /** True while any RUNNING Linux-bridge L3 network needs kernel forwarding. */ + private boolean hasL3LinuxBridge() { + var found = new boolean[]{false}; + context.getNetworks().forEach((uuid, inst) -> { + if (inst.getState() == NetworkState.RUNNING + && inst.getBridgeType() == BridgeType.LINUX + && inst.getUplinkMode() == UplinkMode.L3) + found[0] = true; + }); + return found[0]; } private void updateDefaultRouter() { From 8098bca7ca4f1b10330a098f03f281e7b9ffb0e1 Mon Sep 17 00:00:00 2001 From: HuJK Date: Sun, 5 Jul 2026 22:20:49 +0800 Subject: [PATCH 06/10] ci: add non-ASCII and translation-completeness PR checks Two Python checks wired into a new Checks workflow (push + PR): - check_ascii.py: fails on any non-ASCII byte in a tracked file, so a stray smart-quote, em-dash, arrow or CJK char can't slip into code, comments or config. Genuine i18n / natural-language files are exempt via scripts/ci/ascii_allow.txt (translations, privacy text, docs, ...). - check_translations.py: fails if any values-/strings.xml is missing, has extra, or has stale entries vs the translatable keys in values/strings.xml, so nothing ships half-translated. Fixes so the current tree passes: - Replace the check/cross emoji in the virtualization setup step with inline tinted vector icons (ImageSpan over ic_check/ic_close); widen showDetail() to CharSequence; add status_supported/status_unsupported colors. The mode menu already used circle icons; only a stale glyph comment remained. - Convert typographic dashes/arrows/minus-sign and stray CJK in the hugepage and Try comments to ASCII. - Add the 4 missing zh-rTW strings (vm_exit_crash, vm_exit_guest_panic, vm_exit_watchdog, vm_rebooting). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TaHcvnHaZk8qqpKiS25Hbk --- .github/workflows/checks.yml | 32 +++++ .../droidvm/ui/hugepage/HugePageActivity.java | 6 +- .../droidvm/ui/hugepage/HugePageModel.java | 24 ++-- .../cn/classfun/droidvm/ui/hugepage/Try.java | 6 +- .../ui/setup/base/BaseCheckStepFragment.java | 2 +- .../step/VirtualizationStepFragment.java | 42 +++++-- .../main/res/menu/menu_hugepage_process.xml | 10 +- app/src/main/res/values-zh-rTW/strings.xml | 4 + app/src/main/res/values/colors.xml | 4 + scripts/ci/ascii_allow.txt | 24 ++++ scripts/ci/check_ascii.py | 111 ++++++++++++++++++ scripts/ci/check_translations.py | 94 +++++++++++++++ 12 files changed, 329 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/checks.yml create mode 100644 scripts/ci/ascii_allow.txt create mode 100644 scripts/ci/check_ascii.py create mode 100644 scripts/ci/check_translations.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..4935430 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,32 @@ +name: Checks + +on: + push: + branches: [ "**" ] + pull_request: + branches: [ "**" ] + workflow_dispatch: + +concurrency: + group: checks-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + # No submodules: these checks only look at this repo's own sources. + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + + - name: No non-ASCII in code/config + run: python3 scripts/ci/check_ascii.py + + - name: Translations complete across all locales + run: python3 scripts/ci/check_translations.py 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 a4d4be4..be7a692 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 @@ -160,8 +160,8 @@ private void initialize() { btnViewProcesses.setOnClickListener(v -> startActivity( new Intent(this, HugePageProcessActivity.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 + // while a run is in flight) interrupts it. Listeners are static -- the + // slots aren't recycled -- and the idle/running visibility toggle is // driven by applyAcquireState(). // Short-press runs it (gated to the pressable state); long-press opens the // what-does-this-do dialog with a Run/Cancel choice (always available, even @@ -212,7 +212,7 @@ static void showAcquireInfo(@NonNull Context ctx, int mode, @NonNull Runnable on int msg = mode == 2 ? R.string.hugepage_acquire_v2_explain : mode == 3 ? R.string.hugepage_acquire_v3_explain : R.string.hugepage_acquire_v1_explain; - // Title = "Acquire huge pages" + the mode badge, e.g. "獲取大頁 v1". + // Title = "Acquire huge pages" + the mode badge, e.g. "Acquire huge pages v1". String title = ctx.getString(R.string.hugepage_acquire_pages_title) + " " + ctx.getString(modeLabel); new MaterialAlertDialogBuilder(ctx) 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 8c4b5e9..cd4d583 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 @@ -31,17 +31,17 @@ * *

It hides the {@code gh_hugepage_reserve} module's v6/v7 differences: * callers ask for a high-level operation and never branch on version or poke raw - * sysfs. Version is never decided ahead of time — each mutation is a + * sysfs. Version is never decided ahead of time -- each mutation is a * degradation ladder that just tries the best knob and falls back - * (e.g. the migrating {@code acquire} knob → {@code manual_refill}; - * {@code pool_want=0} soft-disable → {@code rmmod}). The internal {@link Try} + * (e.g. the migrating {@code acquire} knob -> {@code manual_refill}; + * {@code pool_want=0} soft-disable -> {@code rmmod}). The internal {@link Try} * ladder is collapsed to a small {@link Result} ({@code OK / UNSUPPORTED / - * FAILED}) for the GUI — that is the "無法執行就回傳 error" contract. + * FAILED}) for the GUI -- that is the "return an error when it can't run" contract. * *

Reads are cached with short TTLs so the per-second refresh loop doesn't * block on the daemon or re-read a static knob every tick. Each screen owns its * own instance (separate caches); everything here is context-free (pure data) and - * never holds an Activity reference. Mutations do shell I/O — call them off the UI + * never holds an Activity reference. Mutations do shell I/O -- call them off the UI * thread. */ final class HugePageModel { @@ -157,7 +157,7 @@ static final class UsageEntry { * Version-unified snapshot of module + pool state. {@code targetIdeal} is the * one "target" concept across versions: v7's {@code pool_want}, else v6's * read-only {@code pool_target}, else current capacity. {@code deficit} is what - * is still missing toward it ({@code targetIdeal − free − lent}); the acquire + * is still missing toward it ({@code targetIdeal - free - lent}); the acquire * buttons are usable exactly when {@code loaded && deficit > 0}. */ static final class Snapshot { @@ -169,7 +169,7 @@ static final class Snapshot { final long built; // pool_total (capacity assembled) final long free; // pool_avail (in the pool now) final long lent; // served (out to VMs); 0 if the module can't report it (v6) - final long deficit; // max(0, targetIdeal − free − lent) + final long deficit; // max(0, targetIdeal - free - lent) final boolean acquiring; // an acquire worker is running final int acquireMode; // which mode (1/2/3), or -1 if the module can't report it final boolean hasPoolWant; // pool_want knob reported (v7 runtime-resizable) @@ -275,7 +275,7 @@ boolean koAvailable() { * Save a new pool target of {@code pages}. Persists it to settings.prop (for * the next boot) and applies it to the running pool if the live {@code pool_want} * knob exists; on v6 (read-only {@code pool_target}) only the persist takes - * effect. Does not fire an acquire — the caller drives that from the + * effect. Does not fire an acquire -- the caller drives that from the * resulting {@link Snapshot#deficit}. */ @NonNull @@ -295,10 +295,10 @@ Result saveSize(long pages) { * Bring the running pool up (true) or down (false), choosing the deepest action * the module supports: *

    - *
  • enable: not loaded → insmod (targeting the saved size); loaded → + *
  • enable: not loaded -> insmod (targeting the saved size); loaded -> * restore the target by writing the saved {@code pool_want} (v6 loaded is * already active, so this is a no-op there);
  • - *
  • disable: try {@code pool_want=0} (soft — frees the reserve but + *
  • disable: try {@code pool_want=0} (soft -- frees the reserve but * keeps per-VM tracking); if that write can't happen (v6 has no such knob) * fall back to {@code rmmod}.
  • *
@@ -310,7 +310,7 @@ Result setRuntimeEnabled(boolean on) { return collapse(loadLadder(savedWantPages())); // insmod } // Loaded: restore the target. Succeeds on v7; on v6 there's no pool_want - // and a loaded module is already active, so treat a failed write as "已啟用". + // and a loaded module is already active, so treat a failed write as "already enabled". var t = writeKnob("pool_want", Long.toString(savedWantPages())); return t.ok() ? Result.ok("soft-enable") : Result.ok("already-active"); } @@ -346,7 +346,7 @@ Result setBootEnabled(boolean on) { * (1 = original scan, 2 = migrate + system reclaim, 3 = migrate + per-block * evict). Prefers the migrating {@code acquire} knob; on any failure (v6 has no * such knob, or -ENOSYS where this kernel can't run that mode) degrades to - * {@code manual_refill}. Fire-and-return — poll {@link #state()} for completion. + * {@code manual_refill}. Fire-and-return -- poll {@link #state()} for completion. */ @NonNull Result acquire(int mode) { diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java index 84df940..3f787d5 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java @@ -9,9 +9,9 @@ * accumulates into an ordered {@code List>}; a success stops the ladder, * so the caller judges the overall outcome from the last element: *
    - *
  • last {@link #ok()} and alone — success via the primary implementation;
  • - *
  • last {@link #ok()} after earlier fails — success, degraded to a fallback;
  • - *
  • last not {@link #ok()} — failure, and every rung's reason is in the list.
  • + *
  • last {@link #ok()} and alone -- success via the primary implementation;
  • + *
  • last {@link #ok()} after earlier fails -- success, degraded to a fallback;
  • + *
  • last not {@link #ok()} -- failure, and every rung's reason is in the list.
  • *
* * @param the feature's implementation enum (which way it was done) 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 2e44a05..eb9c891 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 @@ -83,7 +83,7 @@ private void requestSkip() { .show(); } - protected void showDetail(String text) { + protected void showDetail(CharSequence text) { tvStepDetail.setText(text); tvStepDetail.setVisibility(VISIBLE); } 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 8a8aee4..5003a80 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 @@ -5,14 +5,24 @@ import static cn.classfun.droidvm.lib.utils.ThreadUtils.threadSleep; import static cn.classfun.droidvm.ui.setup.SetupActivity.CHECK_DELAY; +import android.content.Context; +import android.graphics.drawable.Drawable; import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ImageSpan; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import androidx.annotation.ColorRes; +import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + +import java.util.Objects; import cn.classfun.droidvm.R; import cn.classfun.droidvm.lib.store.vm.VMHypervisor; @@ -46,15 +56,17 @@ private void operationThread() { threadSleep(CHECK_DELAY); boolean success = false; var ctx = requireContext(); - var displayString = new StringBuilder(); + // Per-hypervisor line with an inline check/cross icon (not an emoji) for + // its supported state; a Spannable carries the ImageSpans into the detail + // TextView. Built off the UI thread -- ImageSpan only holds a Drawable, it + // touches no view -- then handed to showDetail() on the UI thread. + var detail = new SpannableStringBuilder(); for (var hyp : VMHypervisor.values()) { if (hyp.getDevicePath() == null) continue; var supported = hyp.isSupported(); - displayString.append(fmt( - "%s: %s\n", - hyp.getDisplayString(ctx), - supported ? "✅" : "❌" - )); + if (detail.length() > 0) detail.append('\n'); + detail.append(hyp.getDisplayString(ctx)).append(": "); + appendStatusIcon(detail, ctx, supported); Log.i(TAG, fmt( "%s: %s", hyp.name().toLowerCase(), @@ -70,10 +82,26 @@ private void operationThread() { } else { showError(R.string.setup_virt_title, R.string.setup_virt_fail); } - showDetail(displayString.toString()); + showDetail(detail); }); } + /** Appends a tinted check (supported) or cross (unsupported) icon inline. */ + private static void appendStatusIcon( + @NonNull SpannableStringBuilder sb, @NonNull Context ctx, boolean supported + ) { + @DrawableRes int icon = supported ? R.drawable.ic_check : R.drawable.ic_close; + @ColorRes int tint = supported ? R.color.status_supported : R.color.status_unsupported; + Drawable d = Objects.requireNonNull(ContextCompat.getDrawable(ctx, icon)).mutate(); + int size = Math.round(ctx.getResources().getDisplayMetrics().density * 16); + d.setBounds(0, 0, size, size); + d.setTint(ContextCompat.getColor(ctx, tint)); + int start = sb.length(); + sb.append(' '); // placeholder glyph the span draws over + sb.setSpan(new ImageSpan(d, ImageSpan.ALIGN_CENTER), + start, sb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + @Override public boolean isHiddenStep() { return !optBoolean("isRoot", false); diff --git a/app/src/main/res/menu/menu_hugepage_process.xml b/app/src/main/res/menu/menu_hugepage_process.xml index 706b70a..a80f7d1 100644 --- a/app/src/main/res/menu/menu_hugepage_process.xml +++ b/app/src/main/res/menu/menu_hugepage_process.xml @@ -2,10 +2,12 @@ - + 虛擬機器"%1$s"已停止。 虛擬機器"%1$s"以退出碼 %2$d 退出。 + 虛擬機器"%1$s"已當機(退出碼 %2$d)。 + 虛擬機器"%1$s"guest 核心 panic(退出碼 %2$d)。 + 虛擬機器"%1$s"停滯,已被看門狗重置(退出碼 %2$d)。 + 正在重新啟動虛擬機器"%1$s"… 虛擬機器"%1$s"啟動失敗 虛擬機器以退出碼 %1$d 退出。\n\n日誌: 無可用日誌。 diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index d0bb4de..665b2be 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -25,4 +25,8 @@ #33808080 #807D9CB3 + + + #FF4CAF50 + #FFF44336 diff --git a/scripts/ci/ascii_allow.txt b/scripts/ci/ascii_allow.txt new file mode 100644 index 0000000..976cf50 --- /dev/null +++ b/scripts/ci/ascii_allow.txt @@ -0,0 +1,24 @@ +# Files permitted to contain non-ASCII bytes. Everything else tracked in the +# repo must be pure 7-bit ASCII (enforced by check_ascii.py). One glob per line; +# blank lines and '#' comments are ignored. Globs are matched against the +# repo-relative path (POSIX '/'), and '*' also spans '/'. +# +# Only genuine natural-language / i18n content belongs here -- source code, +# build files, layouts, manifests and config must stay ASCII. + +# UI strings: translation source (values/) and the translations themselves. +app/src/main/res/values*/strings.xml + +# Privacy policy, one file per language. +app/src/main/assets/privacy/*.txt + +# Localized data: repo-listing display names and language endonyms. +app/src/main/assets/data/repo.yaml +app/src/main/assets/data/languages.yaml + +# Documentation (prose; may name languages/tools in their own script). +*.md + +# Third-party / tool-generated files we don't hand-edit. +LICENSE.txt +gradlew diff --git a/scripts/ci/check_ascii.py b/scripts/ci/check_ascii.py new file mode 100644 index 0000000..2feb45c --- /dev/null +++ b/scripts/ci/check_ascii.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Fail if any tracked text file contains non-ASCII bytes. + +Source code, build files, layouts, manifests and config must be pure 7-bit +ASCII so a stray smart-quote, em-dash, arrow or CJK character can't slip into +code or a comment. Genuine natural-language / i18n content (translations, +privacy text, docs, ...) is exempt via scripts/ci/ascii_allow.txt. + +Scope is the git index (`git ls-files`), so build outputs and submodule +contents are never scanned. Binary files are skipped by extension and by a +NUL-byte sniff. Exit status is 0 when clean, 1 when any violation is found. + +Usage: run from the repo root, or pass the repo root as argv[1]. +""" +import fnmatch +import os +import subprocess +import sys + +ALLOWLIST = "scripts/ci/ascii_allow.txt" + +# Extensions that are always binary -- skipped without reading. +BINARY_EXT = { + ".webp", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".bmp", + ".jar", ".zip", ".zst", ".gz", ".7z", ".apk", ".aar", + ".so", ".a", ".o", ".bin", ".dex", + ".ttf", ".otf", ".woff", ".woff2", + ".keystore", ".jks", ".pdf", +} + +MAX_REPORT_PER_FILE = 5 # don't drown the log on a fully non-ASCII file + + +def load_allow(root): + path = os.path.join(root, ALLOWLIST) + globs = [] + if not os.path.isfile(path): + return globs + with open(path, encoding="utf-8") as fh: + for line in fh: + line = line.split("#", 1)[0].strip() + if line: + globs.append(line) + return globs + + +def allowed(rel, globs): + return any(fnmatch.fnmatch(rel, g) for g in globs) + + +def tracked_files(root): + out = subprocess.check_output( + ["git", "-C", root, "ls-files", "-z"]) + return [p for p in out.decode("utf-8", "surrogateescape").split("\0") if p] + + +def scan(root, rel): + """Return a list of (line, col, char) for non-ASCII bytes, or [].""" + path = os.path.join(root, rel) + if not os.path.isfile(path): # submodule gitlink, symlink to nowhere, ... + return [] + if os.path.splitext(rel)[1].lower() in BINARY_EXT: + return [] + with open(path, "rb") as fh: + data = fh.read() + if b"\x00" in data: # looks binary + return [] + hits = [] + line = col = 1 + for b in data: + if b == 0x0A: + line += 1 + col = 1 + continue + if b > 0x7F: + hits.append((line, col, b)) + col += 1 + return hits + + +def main(): + root = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.getcwd() + globs = load_allow(root) + violations = 0 + files_with_hits = 0 + for rel in tracked_files(root): + if allowed(rel, globs): + continue + hits = scan(root, rel) + if not hits: + continue + files_with_hits += 1 + violations += len(hits) + print(f"{rel}: {len(hits)} non-ASCII byte(s)") + for line, col, b in hits[:MAX_REPORT_PER_FILE]: + print(f" {rel}:{line}:{col}: byte 0x{b:02X}") + if len(hits) > MAX_REPORT_PER_FILE: + print(f" ... and {len(hits) - MAX_REPORT_PER_FILE} more") + + if violations: + print() + print(f"FAIL: {violations} non-ASCII byte(s) in {files_with_hits} file(s).") + print("Use ASCII (e.g. -- for an em-dash, -> for an arrow), or add a") + print(f"genuinely-i18n path to {ALLOWLIST}.") + return 1 + print("OK: no non-ASCII in tracked code/config files.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/check_translations.py b/scripts/ci/check_translations.py new file mode 100644 index 0000000..7c245a1 --- /dev/null +++ b/scripts/ci/check_translations.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Fail if any locale is missing (or has extra) translated string entries. + +The base `values/strings.xml` is the source of truth: every entry that is not +`translatable="false"` must appear in every `values-/strings.xml`, and a +locale must not carry entries that no longer exist in the base. This keeps all +languages at the same set of keys so nothing ships half-translated. + +Entries compared: , and , keyed by name. (Only +presence of the key is checked, not per-plural quantities -- those legitimately +differ by language.) `values-night` and other non-locale qualifiers are ignored +because they carry no strings.xml. + +Exit status is 0 when every locale matches the base, 1 otherwise. + +Usage: run from the repo root, or pass the res/ directory as argv[1]. +""" +import os +import sys +import xml.etree.ElementTree as ET + +DEFAULT_RES = "app/src/main/res" +ENTRY_TAGS = {"string", "plurals", "string-array"} + + +def names(strings_xml): + """(translatable names, translatable=false names) in one strings.xml.""" + root = ET.parse(strings_xml).getroot() + keep, nontrans = set(), set() + for el in root: + if el.tag not in ENTRY_TAGS: + continue + name = el.get("name") + if not name: + continue + if (el.get("translatable") or "").lower() == "false": + nontrans.add(name) + else: + keep.add(name) + return keep, nontrans + + +def main(): + res = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else \ + os.path.join(os.getcwd(), DEFAULT_RES) + + base_xml = os.path.join(res, "values", "strings.xml") + if not os.path.isfile(base_xml): + print(f"FAIL: base strings.xml not found at {base_xml}") + return 1 + base, base_nontrans = names(base_xml) + print(f"base (values/strings.xml): {len(base)} translatable " + f"({len(base_nontrans)} translatable=false)") + + locales = [] + for d in sorted(os.listdir(res)): + if not d.startswith("values-"): + continue + sp = os.path.join(res, d, "strings.xml") + if os.path.isfile(sp): # non-locale qualifiers (e.g. values-night) have none + locales.append((d, sp)) + + if not locales: + print("OK: no translation locales to check.") + return 0 + + failed = False + for d, sp in locales: + loc, _ = names(sp) + missing = sorted(base - loc) + extra = sorted(loc - base) + stale = sorted(loc & base_nontrans) # translating a non-translatable key + if not missing and not extra and not stale: + print(f" OK {d}: {len(loc)}/{len(base)}") + continue + failed = True + print(f" FAIL {d}: {len(loc)}/{len(base)}") + for n in missing: + print(f" missing: {n}") + for n in extra: + print(f" extra: {n} (not in base)") + for n in stale: + print(f" stale: {n} (base marks it translatable=false)") + + if failed: + print() + print("FAIL: translations are out of sync with the base strings.") + return 1 + print("OK: every locale matches the base string set.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 064d4472dff8089da93fb296e42a321e45af6b96 Mon Sep 17 00:00:00 2001 From: HuJK Date: Mon, 6 Jul 2026 13:28:02 +0800 Subject: [PATCH 07/10] review (PR #34): rename VMConsoleRouter, relocate Try, harden shell calls, ASCII base strings Addresses BigfootACA's review comments: - Rename VmConsoleRouter -> VMConsoleRouter (VM initialism); update references. - Move the generic Try degradation-ladder helper to lib/utils for reuse (now public; javadoc de-specialised from HugePageModel). - HugePageModel: wrap the module path in escapedString(); build strings with fmt() instead of + concatenation. - HugePageActivity: fmt() for the acquire-dialog title. - DefaultRouterWatcher: read the sysctl via shellReadFile(). - Base values/strings.xml is ASCII-only now (typographic ellipsis/em-dash -> "..." / "-"), and the non-ASCII allowlist narrows to values-*/strings.xml so the English source file is checked too. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TaHcvnHaZk8qqpKiS25Hbk --- .../network/backend/DefaultRouterWatcher.java | 18 ++++++++------- .../{ui/hugepage => lib/utils}/Try.java | 22 +++++++++---------- .../droidvm/ui/hugepage/HugePageActivity.java | 14 ++++++------ .../droidvm/ui/hugepage/HugePageModel.java | 21 ++++++++++-------- .../droidvm/ui/main/vm/MainVMFragment.java | 4 ++-- ...onsoleRouter.java => VMConsoleRouter.java} | 4 ++-- .../droidvm/ui/vm/info/ConsoleButton.java | 14 ++++++------ app/src/main/res/values/strings.xml | 20 ++++++++--------- scripts/ci/ascii_allow.txt | 5 +++-- 9 files changed, 64 insertions(+), 58 deletions(-) rename app/src/main/java/cn/classfun/droidvm/{ui/hugepage => lib/utils}/Try.java (70%) rename app/src/main/java/cn/classfun/droidvm/ui/vm/console/{VmConsoleRouter.java => VMConsoleRouter.java} (98%) 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 ac7a6e0..c6b8e59 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,5 +1,6 @@ package cn.classfun.droidvm.daemon.network.backend; +import static cn.classfun.droidvm.lib.utils.FileUtils.shellReadFile; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.util.Log; @@ -193,7 +194,7 @@ private void reconcileRules(boolean ipv6, @NonNull List tables) { // lowest priority so it is evaluated first. var desired = new ArrayList(); for (int i = 0; i < tables.size(); i++) - desired.add((RULE_PRIORITY_BASE + i) + " " + tables.get(i)); + desired.add(fmt("%d %s", RULE_PRIORITY_BASE + i, tables.get(i))); // Group our installed rules per device as "priority table" specs. Ours // are the plain rules: an iif owned by a bridge, a lookup action, no @@ -214,7 +215,7 @@ private void reconcileRules(boolean ipv6, @NonNull List tables) { if (dev.startsWith(br)) { ours = true; break; } if (!ours) continue; installed.computeIfAbsent(dev, k -> new ArrayList<>()) - .add(r.optInt("priority", 0) + " " + r.optInt("table", 0)); + .add(fmt("%d %d", r.optInt("priority", 0), r.optInt("table", 0))); if (r.optBoolean("detached", false)) detached.add(dev); } installed.values().forEach(specs -> specs.sort(Comparator.comparingInt( @@ -288,17 +289,18 @@ private void reassertForwarding() { /** * Writes 1 to {@code path} only if it is not already 1; logs corrections. - * Both the read and the write go through {@code runQuiet} so the 5s poll - * stays out of logcat -- {@code RunContext.run} would log a "Running - * command: cat ..." line every tick. The only line this ever emits is the - * warning below, and only when netd actually reset the value. + * The read ({@code shellReadFile}) and the write ({@code runQuiet}) are both + * quiet, so the 5s poll stays out of logcat -- {@code RunContext.run} would + * otherwise log a "Running command: cat ..." line every tick. The only line + * this emits on a normal tick is the warning below, and only when netd + * actually reset the value. */ private void ensureSysctlOne(@NonNull String path, @NonNull String name) { - var cur = RunUtils.runQuiet("cat " + path).getOutString(); + var cur = shellReadFile(path); if ("1".equals(cur)) return; Log.w(TAG, fmt("%s=%s (netd reset it); re-enabling forwarding", name, cur.isEmpty() ? "?" : cur)); - RunUtils.runQuiet("echo 1 > " + path); + RunUtils.runQuiet(fmt("echo 1 > %s", path)); } /** True while any RUNNING Linux-bridge L3 network needs kernel forwarding. */ diff --git a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/Try.java similarity index 70% rename from app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java rename to app/src/main/java/cn/classfun/droidvm/lib/utils/Try.java index 3f787d5..ed195bb 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/hugepage/Try.java +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/Try.java @@ -1,13 +1,13 @@ -package cn.classfun.droidvm.ui.hugepage; +package cn.classfun.droidvm.lib.utils; import androidx.annotation.Nullable; /** * One implementation's attempt within a degradation ladder: which implementation * ran, plus its return value on success or its failure reason otherwise. The pair - * {@code (value | error, impl)} is what a {@link HugePageModel} feature call - * accumulates into an ordered {@code List>}; a success stops the ladder, - * so the caller judges the overall outcome from the last element: + * {@code (value | error, impl)} is what a feature call accumulates into an ordered + * {@code List>}; a success stops the ladder, so the caller judges the + * overall outcome from the last element: *
    *
  • last {@link #ok()} and alone -- success via the primary implementation;
  • *
  • last {@link #ok()} after earlier fails -- success, degraded to a fallback;
  • @@ -17,15 +17,15 @@ * @param the feature's implementation enum (which way it was done) * @param the value produced on success ({@link Void} for pure actions) */ -final class Try { +public final class Try { /** The implementation this attempt used. */ - final I impl; + public final I impl; /** The value produced on success; {@code null} on failure (or for {@link Void}). */ @Nullable - final V value; + public final V value; /** The failure reason; {@code null} on success. */ @Nullable - final String error; + public final String error; private Try(I impl, @Nullable V value, @Nullable String error) { this.impl = impl; @@ -33,16 +33,16 @@ private Try(I impl, @Nullable V value, @Nullable String error) { this.error = error; } - static Try ok(I impl, @Nullable V value) { + public static Try ok(I impl, @Nullable V value) { return new Try<>(impl, value, null); } - static Try fail(I impl, String error) { + public static Try fail(I impl, String error) { return new Try<>(impl, null, error); } /** True if this implementation succeeded (a non-null error means failure). */ - boolean ok() { + public boolean ok() { return error == null; } } 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 be7a692..bcd32a9 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 @@ -6,6 +6,7 @@ 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.runList; +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; @@ -213,8 +214,9 @@ static void showAcquireInfo(@NonNull Context ctx, int mode, @NonNull Runnable on : mode == 3 ? R.string.hugepage_acquire_v3_explain : R.string.hugepage_acquire_v1_explain; // Title = "Acquire huge pages" + the mode badge, e.g. "Acquire huge pages v1". - String title = ctx.getString(R.string.hugepage_acquire_pages_title) - + " " + ctx.getString(modeLabel); + String title = fmt("%s %s", + ctx.getString(R.string.hugepage_acquire_pages_title), + ctx.getString(modeLabel)); new MaterialAlertDialogBuilder(ctx) .setTitle(title) .setMessage(msg) @@ -365,7 +367,7 @@ private void updateUI( String name = vmMap.get(pid); if (name == null) name = getString(R.string.hugepage_proc_pid, pid); // Two stacked lines: label over capacity. - usedLabels[i] = name + "\n" + SizeUtils.formatSize(ownerPages * PAGE_SIZE); + usedLabels[i] = fmt("%s\n%s", name, SizeUtils.formatSize(ownerPages * PAGE_SIZE)); seg += ownerPages; } // "used" is the sum of the segments the bar draws, so the caption @@ -385,11 +387,9 @@ private void updateUI( setPagesString(tvPoolTotal, R.string.hugepage_stat_pool_total, held); setPagesString(tvPoolSize, R.string.hugepage_stat_pool_size, poolWant); segPoolBar.setStorage(usedColors, usedValues, usedLabels, - poolAvail, getString(R.string.hugepage_bar_available) - + "\n" + SizeUtils.formatSize(poolAvail * PAGE_SIZE), + poolAvail, fmt("%s\n%s", getString(R.string.hugepage_bar_available), SizeUtils.formatSize(poolAvail * PAGE_SIZE)), HugePageColor.pending(this), deficit, - getString(R.string.hugepage_proc_deficit) - + "\n" + SizeUtils.formatSize(deficit * PAGE_SIZE), + fmt("%s\n%s", getString(R.string.hugepage_proc_deficit), SizeUtils.formatSize(deficit * PAGE_SIZE)), poolWant); } else { rowStatState.setValue(getString(R.string.hugepage_stats_unavailable)); 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 cd4d583..f26d7e9 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 @@ -2,8 +2,10 @@ 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.escapedString; import static cn.classfun.droidvm.lib.utils.RunUtils.run; import static cn.classfun.droidvm.lib.utils.RunUtils.runList; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import android.os.SystemClock; @@ -24,6 +26,7 @@ import cn.classfun.droidvm.lib.daemon.DaemonConnection; import cn.classfun.droidvm.lib.run.RunResult; +import cn.classfun.droidvm.lib.utils.Try; /** * The single huge-page facade both screens ({@link HugePageActivity}, @@ -365,7 +368,7 @@ Result acquire(int mode) { if (writeKnob("acquire", Integer.toString(m)).ok()) { // impl names the actual migrating mode that ran ("v1".."v3"); // degraded when it's below what was asked for. - return Result.ok("v" + m, m != mode); + return Result.ok(fmt("v%d", m), m != mode); } } var refill = writeKnob("manual_refill", "1"); @@ -428,8 +431,8 @@ private static Usage usageOf(@NonNull List>> log) { @NonNull private List> loadLadder(long pages) { var log = new ArrayList>(); - var w = "pool_want=\"" + pages + "\""; - var t = "pool_target=\"" + pages + "\""; + var w = fmt("pool_want=\"%d\"", pages); + var t = fmt("pool_target=\"%d\"", pages); // Pass BOTH size params first. A lenient kernel silently ignores the param // the module doesn't have, so it still gets the right size via the one it // does - pool_want (v7) or pool_target (v6). This is essential: on a v6 @@ -438,7 +441,7 @@ private List> loadLadder(long pages) { // Strict kernels reject the unknown param, so fall back to each key alone, // then a bare (default-size) load. if (rung(null, log, LoadImpl.BOTH, - () -> insmod(LoadImpl.BOTH, w + " " + t))) return log; + () -> insmod(LoadImpl.BOTH, fmt("%s %s", w, t)))) return log; if (rung(null, log, LoadImpl.POOL_WANT, () -> insmod(LoadImpl.POOL_WANT, w))) return log; if (rung(null, log, LoadImpl.POOL_TARGET, @@ -449,8 +452,8 @@ private List> loadLadder(long pages) { private static Try insmod(@NonNull LoadImpl impl, @Nullable String arg) { var r = (arg == null) - ? run("insmod \"%s\"", KO_PATH) - : run("insmod \"%s\" %s", KO_PATH, arg); + ? run("insmod %s", escapedString(KO_PATH)) + : run("insmod %s %s", escapedString(KO_PATH), arg); return r.isSuccess() ? Try.ok(impl, null) : Try.fail(impl, reason(r, "insmod")); } @@ -511,8 +514,8 @@ private static Try writeSettings(long pages) { private static String reason(@NonNull RunResult r, @NonNull String what) { var msg = r.getErrString().trim(); if (msg.isEmpty()) msg = r.getOutString().trim(); - if (msg.isEmpty()) msg = "exit " + r.getCode(); - return what + ": " + msg; + if (msg.isEmpty()) msg = fmt("exit %d", r.getCode()); + return fmt("%s: %s", what, msg); } /** @@ -619,7 +622,7 @@ private Try> usageFromKo() { out.sort((a, b) -> Long.compare(b.pages, a.pages)); return Try.ok(Source.KO, out); } catch (Exception e) { - return Try.fail(Source.KO, "KO read: " + e.getMessage()); + return Try.fail(Source.KO, fmt("KO read: %s", e.getMessage())); } } 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 442b18a..e6b56d4 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 @@ -28,7 +28,7 @@ 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.console.VmConsoleRouter; +import cn.classfun.droidvm.ui.vm.console.VMConsoleRouter; import cn.classfun.droidvm.ui.vm.edit.VMEditActivity; import cn.classfun.droidvm.ui.vm.info.VMInfoActivity; @@ -135,7 +135,7 @@ public void onVMStateChanged(UUID vmId, VMState state) { if (config == null) return; // Same routing as the VM-info console button (native -> VNC -> // serial), so a VNC/native VM auto-opens its display, not always UART. - VmConsoleRouter.openDefault(requireContext(), vmId, config, true); + VMConsoleRouter.openDefault(requireContext(), vmId, config, true); } }); } 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 similarity index 98% rename from app/src/main/java/cn/classfun/droidvm/ui/vm/console/VmConsoleRouter.java rename to app/src/main/java/cn/classfun/droidvm/ui/vm/console/VMConsoleRouter.java index 923f7de..a8a0146 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 @@ -24,8 +24,8 @@ * this in one place is why both paths agree instead of the list always opening * the UART console regardless of the VM's display. */ -public final class VmConsoleRouter { - private VmConsoleRouter() { +public final class VMConsoleRouter { + private VMConsoleRouter() { } /** diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/info/ConsoleButton.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/info/ConsoleButton.java index 70597d3..5cfda47 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/info/ConsoleButton.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/info/ConsoleButton.java @@ -22,7 +22,7 @@ import cn.classfun.droidvm.lib.store.base.DataItem; import cn.classfun.droidvm.lib.store.vm.VMState; import cn.classfun.droidvm.lib.ui.IconItemAdapter; -import cn.classfun.droidvm.ui.vm.console.VmConsoleRouter; +import cn.classfun.droidvm.ui.vm.console.VMConsoleRouter; public final class ConsoleButton { private static final String TAG = "VMInfoActivity"; @@ -50,9 +50,9 @@ void showConsoleChooser() { void openDefaultConsole() { if (parent.config == null) return; - // Shared with the VM-list auto-open-after-start (VmConsoleRouter), so both + // Shared with the VM-list auto-open-after-start (VMConsoleRouter), so both // pick the same view: native -> VNC -> serial (uart, then stdio). - VmConsoleRouter.openDefault(parent, parent.vmId, parent.config, + VMConsoleRouter.openDefault(parent, parent.vmId, parent.config, parent.currentState != VMState.STOPPED); } @@ -113,22 +113,22 @@ private void buildConsoleChooserDialog(@Nullable JSONArray streams) { } private void openConsole(@NonNull String stream) { - VmConsoleRouter.openConsole(parent, parent.vmId, parent.config, stream, + VMConsoleRouter.openConsole(parent, parent.vmId, parent.config, stream, parent.currentState == VMState.STOPPED); } private void openNativeDisplay() { if (parent.config == null) return; - VmConsoleRouter.openNative(parent, parent.vmId, parent.config); + VMConsoleRouter.openNative(parent, parent.vmId, parent.config); } private void openVncDisplay() { if (parent.config == null) return; - VmConsoleRouter.openVnc(parent, parent.vmId, parent.config); + VMConsoleRouter.openVnc(parent, parent.vmId, parent.config); } private void openVncExtDisplay() { if (parent.config == null) return; - VmConsoleRouter.openVncExt(parent, parent.vmId, parent.config); + VMConsoleRouter.openVncExt(parent, parent.vmId, parent.config); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ab0997..64c71f4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -115,7 +115,7 @@ VM \"%1$s\" crashed (code %2$d). VM \"%1$s\" hit a kernel panic in the guest (code %2$d). VM \"%1$s\" stalled and was reset by the watchdog (code %2$d). - Rebooting VM \"%1$s\"… + Rebooting VM \"%1$s\"... VM \"%1$s\" Failed The virtual machine exited with code %1$d.\n\nLogs: No logs available. @@ -972,18 +972,18 @@ Uses alloc_contig_pages to collect huge pages Uses try_to_free_mem_cgroup_pages to push other apps into swap/zram\nthen alloc_contig_range to collect huge pages Uses folio_isolate_lru + reclaim_pages to precisely push pages into swap/zram\nthen alloc_contig_range to collect huge pages - Acquiring huge pages… + Acquiring huge pages... Acquire done - Downgraded — ran %1$s instead + Downgraded - ran %1$s instead Stop acquiring Acquire complete: reached %1$s Acquire stopped: got %1$s of %2$s (system can\'t migrate more right now) - Module short; trying userspace assist… + Module short; trying userspace assist... Acquire huge pages Pool Size Pool size saved (effective after reboot) Pool size saved & applied - Acquiring — can\'t shrink now, wait for it to finish + Acquiring - can\'t shrink now, wait for it to finish Pool can\'t be smaller than the running VMs\' total memory (%1$s) Failed to save pool size Module enabled (effective after reboot) @@ -1006,7 +1006,7 @@ Served: %1$d pages (%2$s) Usage: %1$d pages (%2$s) Unattributed - Unattributed — freed %1$d / reused %2$d pages + Unattributed - freed %1$d / reused %2$d pages Waiting for acquire pid %1$d Available @@ -1020,11 +1020,11 @@ zombie unknown / exited Scan mode - Scanning… + Scanning... Display mode Module attribution (KO) System scan (THP) - Module unavailable — using scan mode + Module unavailable - using scan mode No processes are currently using the reserved hugepage pool. No VM is holding reserved huge pages. No running VM is using huge pages. @@ -1032,8 +1032,8 @@ Loaded module has no per-VM attribution API (old build). Switch to System scan (menu). Kill Kill %1$s? - SIGTERM (15) — graceful - SIGKILL (9) — force + SIGTERM (15) - graceful + SIGKILL (9) - force Signal sent Failed to send signal Kernel stack (pid %1$d) diff --git a/scripts/ci/ascii_allow.txt b/scripts/ci/ascii_allow.txt index 976cf50..503a8e6 100644 --- a/scripts/ci/ascii_allow.txt +++ b/scripts/ci/ascii_allow.txt @@ -6,8 +6,9 @@ # Only genuine natural-language / i18n content belongs here -- source code, # build files, layouts, manifests and config must stay ASCII. -# UI strings: translation source (values/) and the translations themselves. -app/src/main/res/values*/strings.xml +# Translated UI strings only. The base values/strings.xml (English source) must +# stay ASCII -- it is deliberately not matched by this locale-qualified glob. +app/src/main/res/values-*/strings.xml # Privacy policy, one file per language. app/src/main/assets/privacy/*.txt From 7595658e13b22613bc15326143ca3e0d00138ea6 Mon Sep 17 00:00:00 2001 From: HuJK Date: Mon, 6 Jul 2026 13:28:09 +0800 Subject: [PATCH 08/10] ci: enforce fmt() over complex string concatenation; convert existing ones Add scripts/ci/check_string_concat.py (wired into the Checks workflow): a Java-literal-aware scanner that fails when a bare + joins a string literal with a runtime expression ("x=" + v), which reads better as fmt("x=%s", v). It masks comments and string/char literals first, so + inside a literal ("wlan+", "%s+", "\s+") and literal+literal constants are not flagged; a line can opt out with a trailing // concat-ok comment. A glance-obvious accumulator append stays as-is: a += statement with at most ONE + (s += "x" + y) reads clearly and is exempt; a += chaining two or more + (s += "a" + b + "c") loses the exemption and is flagged. Only concatenation that doesn't read at a glance is pushed to fmt(). The + count is scoped per statement (to the next ; { }). Convert the pre-existing complex concatenations across the codebase to fmt() (mostly Log calls); route filesystem-path joins through pathJoin(). Simple single-+ += appends are left as +=. StringUtils.pathJoin's own body keeps its + with a // concat-ok note -- it is the canonical joiner and fmt() there would be a hot-path cost. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TaHcvnHaZk8qqpKiS25Hbk --- .github/workflows/checks.yml | 3 + .../daemon/display/DaemonSystemContext.java | 3 +- .../daemon/display/NativeDisplayBinder.java | 13 +- .../daemon/network/NetworkInstanceStore.java | 6 +- .../backend/gvisor/GvisorBridgeBackend.java | 2 +- .../lib/download/DiskDownloadManager.java | 8 +- .../lib/download/DiskDownloadService.java | 1 + .../lib/store/network/NetworkConfig.java | 5 +- .../lib/store/network/NetworkStore.java | 7 +- .../droidvm/lib/store/vm/NativeDisplay.java | 2 +- .../droidvm/lib/ui/termux/TerminalFonts.java | 3 +- .../droidvm/lib/utils/AssetUtils.java | 2 +- .../droidvm/lib/utils/StringUtils.java | 2 +- .../droidvm/ui/vm/NicLeaseAllocator.java | 3 +- .../droidvm/ui/vm/boot/BootEntries.java | 3 +- .../droidvm/ui/vm/boot/BootMenuDialog.java | 3 +- .../display/DisplayProvider.java | 5 +- .../nativedisplay/input/InputForwarder.java | 4 +- .../droidvm/ui/vm/info/VMInfoActivity.java | 9 +- scripts/ci/check_string_concat.py | 158 ++++++++++++++++++ 20 files changed, 204 insertions(+), 38 deletions(-) create mode 100644 scripts/ci/check_string_concat.py diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 4935430..78e7157 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -30,3 +30,6 @@ jobs: - name: Translations complete across all locales run: python3 scripts/ci/check_translations.py + + - name: Use fmt() instead of string concatenation + run: python3 scripts/ci/check_string_concat.py 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 f69ec8b..3437a54 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,5 +1,6 @@ package cn.classfun.droidvm.daemon.display; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.annotation.SuppressLint; import android.content.Context; import android.os.Looper; @@ -34,7 +35,7 @@ public static synchronized void init() { try { if (Looper.myLooper() == null) Looper.prepareMainLooper(); } catch (Throwable t) { - Log.w(TAG, "prepareMainLooper: " + t); + Log.w(TAG, fmt("prepareMainLooper: %s", t)); } try { Class at = Class.forName("android.app.ActivityThread"); 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 97d8ce6..6b86649 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,5 +1,6 @@ package cn.classfun.droidvm.daemon.display; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import static cn.classfun.droidvm.BuildConfig.APPLICATION_ID; import android.content.Intent; @@ -78,7 +79,7 @@ private static void broadcast(@NonNull IBinder binder, @NonNull String nonce) { intent.setPackage(APPLICATION_ID); intent.putExtra(NativeDisplay.EXTRA_BUNDLE, bundle); context.sendBroadcast(intent); - Log.i(TAG, "broadcast native-display binder (nonce=" + nonce + ")"); + Log.i(TAG, fmt("broadcast native-display binder (nonce=%s)", nonce)); } private static IBinder smCall(@NonNull String method, @NonNull String name) { @@ -87,14 +88,14 @@ private static IBinder smCall(@NonNull String method, @NonNull String name) { Method m = sm.getMethod(method, String.class); return (IBinder) m.invoke(null, name); } catch (Exception e) { - Log.w(TAG, method + " reflection failed: " + e.getMessage()); + Log.w(TAG, fmt("%s reflection failed: %s", method, e.getMessage())); return null; } } private static IBinder waitForServiceWithTimeout(@NonNull String name, long timeoutMs) { var holder = new IBinder[1]; - var t = new Thread(() -> holder[0] = smCall("waitForService", name), "WaitSvc-" + name); + var t = new Thread(() -> holder[0] = smCall("waitForService", name), fmt("WaitSvc-%s", name)); t.setDaemon(true); t.start(); try { @@ -105,7 +106,7 @@ private static IBinder waitForServiceWithTimeout(@NonNull String name, long time } private static IBinder doWaitForDisplayBinder(@NonNull String serviceName) { - Log.i(TAG, "waitForDisplayBinder('" + serviceName + "')"); + Log.i(TAG, fmt("waitForDisplayBinder('%s')", serviceName)); var direct = smCall("checkService", serviceName); if (direct != null) { Log.i(TAG, "OK: got display binder directly from ServiceManager"); @@ -117,8 +118,8 @@ private static IBinder doWaitForDisplayBinder(@NonNull String serviceName) { Log.i(TAG, "OK: got display binder via waitForService"); return waited; } - Log.e(TAG, "'" + serviceName + "' not found - is crosvm running with " - + "--android-display-service " + serviceName + "?"); + Log.e(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/network/NetworkInstanceStore.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/NetworkInstanceStore.java index f0f4fd5..e69a997 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 @@ -52,8 +52,7 @@ protected boolean load(@NonNull DataStore store, @NonNull JSONO JsonUtils.forEachArray(obj, getTypeName(), (JSONObject entry) -> { var migrated = NetworkConfig.migrate(entry); if (migrated == null) { - Log.w(TAG, "Skipping network config with unsupported schema: " - + entry.optString("name")); + Log.w(TAG, fmt("Skipping network config with unsupported schema: %s", entry.optString("name"))); return; } if (migrated != entry) { @@ -63,8 +62,7 @@ protected boolean load(@NonNull DataStore store, @NonNull JSONO try { NetworkConfigValidator.validate(new NetworkConfig(migrated)); } catch (Exception e) { - Log.w(TAG, "Skipping legacy network that failed migration/validation: " - + entry.optString("name"), e); + Log.w(TAG, fmt("Skipping legacy network that failed migration/validation: %s", entry.optString("name")), e); return; } } 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 87af1a3..e6c8bb6 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 @@ -377,7 +377,7 @@ private void postForward( fmt("/api/v1/gateways/%d/forwards", vlanId), rule); if (resp.isSuccess()) { var id = resp.json().optString("id", ""); - if (!id.isEmpty()) ids.add(vlanId + "/" + id); + if (!id.isEmpty()) ids.add(fmt("%d/%s", vlanId, id)); } else { failures.add(fmt("%s %s (%s)", proto, bind, briefBody(resp.body))); } 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 f5547a9..0d2ff6b 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 @@ -231,7 +231,7 @@ static void runDownload(@NonNull Context context, long id) { var ctx = context.getApplicationContext(); var d = JOBS.get(id); if (d == null) return; - var part = new File(d.folder, d.name + ".part"); + var part = new File(d.folder, fmt("%s.part", d.name)); var dest = new File(d.folder, d.name); try { var parent = dest.getParentFile(); @@ -285,15 +285,15 @@ private static void downloadOnce(Download d, File part) throws IOException { conn.setInstanceFollowRedirects(true); conn.setRequestProperty("User-Agent", d.userAgent); conn.setRequestProperty("Accept-Encoding", "identity"); - if (existing > 0) conn.setRequestProperty("Range", "bytes=" + existing + "-"); + if (existing > 0) conn.setRequestProperty("Range", fmt("bytes=%d-", existing)); d.activeConn = conn; try { int code = conn.getResponseCode(); boolean resumed = code == HttpURLConnection.HTTP_PARTIAL; // 206 if (code != HttpURLConnection.HTTP_OK && code != HttpURLConnection.HTTP_PARTIAL) - throw new IOException("HTTP " + code); + throw new IOException(fmt("HTTP %d", code)); Log.i(TAG, fmt("Download #%d %s from %s (HTTP %d)", - d.id, existing > 0 ? "resuming@" + existing : "starting", conn.getURL(), code)); + d.id, existing > 0 ? fmt("resuming@%d", existing) : "starting", conn.getURL(), code)); // Server ignored our Range and is sending the whole file again. long start = resumed ? existing : 0; d.total = resolveTotal(conn, resumed, start); 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 d582423..6ea6a70 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,5 +1,6 @@ package cn.classfun.droidvm.lib.download; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import static cn.classfun.droidvm.lib.size.SizeUtils.formatSize; import static cn.classfun.droidvm.lib.utils.StringUtils.formatDuration; 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 1fdd226..d44e27a 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,5 +1,6 @@ package cn.classfun.droidvm.lib.store.network; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.util.Log; import androidx.annotation.NonNull; @@ -30,7 +31,7 @@ public NetworkConfig() { public NetworkConfig(@NonNull JSONObject obj) throws JSONException { item.set(obj); if (!isSupportedSchema(obj)) throw new JSONException( - "Unsupported network config schema (expected " + SCHEMA_VERSION + ")" + fmt("Unsupported network config schema (expected %s)", SCHEMA_VERSION) ); } @@ -57,7 +58,7 @@ public static JSONObject migrate(@NonNull JSONObject obj) { try { return migrateLegacyToV2(obj); } catch (Exception e) { - Log.w(TAG, "Failed to migrate legacy network config: " + obj.optString("name"), e); + Log.w(TAG, fmt("Failed to migrate legacy network config: %s", obj.optString("name")), e); return null; } } 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 89d2400..b2b7f7d 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,5 +1,6 @@ package cn.classfun.droidvm.lib.store.network; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.content.Context; import android.util.Log; @@ -42,8 +43,7 @@ protected boolean load(@NonNull DataStore store, @NonNull JSONObj JsonUtils.forEachArray(obj, getTypeName(), (JSONObject entry) -> { var migrated = NetworkConfig.migrate(entry); if (migrated == null) { - Log.w(TAG, "Skipping network config with unsupported schema: " - + entry.optString("name")); + Log.w(TAG, fmt("Skipping network config with unsupported schema: %s", entry.optString("name"))); return; } if (migrated == entry) { // already current schema @@ -58,8 +58,7 @@ protected boolean load(@NonNull DataStore store, @NonNull JSONObj cfg = new NetworkConfig(migrated); NetworkConfigValidator.validate(cfg); } catch (Exception e) { - Log.w(TAG, "Skipping legacy network that failed migration/validation: " - + entry.optString("name"), e); + Log.w(TAG, fmt("Skipping legacy network that failed migration/validation: %s", entry.optString("name")), e); return; } store.add(cfg); 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 c6951f6..b044576 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 @@ -50,7 +50,7 @@ public static String serviceName(@NonNull VMConfig config) { /** Same as {@link #serviceName(VMConfig)} but from a raw VM id (e.g. an Intent extra). */ @NonNull public static String serviceNameFromId(@NonNull String vmId) { - return "droidvm_disp_" + sanitize(vmId); + return fmt("droidvm_disp_%s", sanitize(vmId)); } /** The socket path crosvm connects to for [vmKey]'s [channel]. Must match across all callers. */ 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 d52fe68..d6b64d9 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,5 +1,6 @@ package cn.classfun.droidvm.lib.ui.termux; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.content.Context; import android.graphics.Typeface; import android.util.Log; @@ -49,7 +50,7 @@ public static Typeface load(@NonNull Context ctx) { try { return Typeface.createFromFile(userFont); } catch (Exception e) { - Log.w(TAG, "Ignoring unreadable user font " + userFont, e); + Log.w(TAG, fmt("Ignoring unreadable user font %s", userFont), e); } } if (bundled == null) { 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 a6776b2..4b04513 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 @@ -316,7 +316,7 @@ private static void extractTarXz(@NonNull InputStream rawIn, @NonNull File destR if (isZeroBlock(header)) break; var name = cString(header, 0, 100); var prefix = cString(header, 345, 155); - var path = prefix.isEmpty() ? name : prefix + "/" + name; + var path = prefix.isEmpty() ? name : pathJoin(prefix, name); var size = parseOctal(header, 124, 12); var mode = (int) parseOctal(header, 100, 8); var type = (char) (header[156] & 0xff); 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 f1ab98d..3d1a461 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 @@ -137,7 +137,7 @@ public static String extensionLower(@NonNull String path) { public static String pathJoin(@NonNull String base, @NonNull String child) { if (base.endsWith("/")) base = base.substring(0, base.length() - 1); if (child.startsWith("/")) child = child.substring(1); - return base + "/" + child; + return base + "/" + child; // concat-ok: the canonical path join; fmt() would be a hot-path perf hit } @NonNull 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 58bce95..018fb1b 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,5 +1,6 @@ package cn.classfun.droidvm.ui.vm; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.content.Context; import android.util.Log; @@ -60,7 +61,7 @@ public static void resolveAndPersist(@NonNull VMConfig config, @NonNull Context long offset = firstFree(usedOffsets(vmStore, config, selfId, network, vlan), vlan, net4); if (offset < 0) { - Log.w(TAG, "No free DHCPv4 offset for a NIC on network " + netId); + Log.w(TAG, fmt("No free DHCPv4 offset for a NIC on network %s", netId)); return; } nic.setDhcp4Offset(offset); 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 5466400..de83312 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,5 +1,6 @@ package cn.classfun.droidvm.ui.vm.boot; +import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import android.content.Context; @@ -176,7 +177,7 @@ public static void analyzeUrl( @NonNull Context ctx, @NonNull String url, int timeoutSecs, @NonNull OnProgress onProgress, @NonNull OnScan onScan ) { - var lbx = ctx.getApplicationInfo().nativeLibraryDir + "/liblbx.so"; + var lbx = pathJoin(ctx.getApplicationInfo().nativeLibraryDir, "liblbx.so"); var thread = new Thread( () -> runAnalyze(lbx, url, timeoutSecs, onProgress, onScan), "lbx-analyze"); thread.setDaemon(true); 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 238ebd8..cd84b5b 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,5 +1,6 @@ package cn.classfun.droidvm.ui.vm.boot; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import static android.view.View.GONE; import static android.view.View.VISIBLE; import static cn.classfun.droidvm.lib.store.enums.Enums.optEnum; @@ -251,7 +252,7 @@ private static void updateSummary( summary.setVisibility(GONE); return; } - var line = entry.kernel + " | " + entry.source; + var line = fmt("%s | %s", entry.kernel, entry.source); var cmdline = entry.effectiveCmdline(boot.isVdafix()); if (!cmdline.isEmpty()) line += "\n" + cmdline; summary.setVisibility(VISIBLE); 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 f58252a..524e5db 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,5 +1,6 @@ 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.DeadObjectException; @@ -105,7 +106,7 @@ private void fetchBinder() { 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, "display binder unavailable after " + MAX_BINDER_ATTEMPTS + " attempts"); + Log.e(TAG, fmt("display binder unavailable after %d attempts", MAX_BINDER_ATTEMPTS)); mainHandler.post(() -> onConnected.accept(false)); return; } @@ -130,7 +131,7 @@ private void onBinderReady(@NonNull IBinder binder) { onDisplayConfig.accept(config); } } catch (Exception e) { - Log.w(TAG, "getDisplayConfig unavailable, using default " + width + "x" + height); + Log.w(TAG, fmt("getDisplayConfig unavailable, using default %dx%d", width, height)); } onConnected.accept(true); applyPendingSurface(); 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 1639814..ce192c4 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 @@ -68,11 +68,11 @@ private void submit(@NonNull String name, @NonNull Runnable block) { try { block.run(); } catch (Exception e) { - Log.e(TAG, name + " failed", e); + Log.e(TAG, fmt("%s failed", name), e); } }); } catch (Exception e) { - Log.d(TAG, name + " dropped: " + e.getMessage()); + Log.d(TAG, fmt("%s dropped: %s", name, e.getMessage())); } } diff --git a/app/src/main/java/cn/classfun/droidvm/ui/vm/info/VMInfoActivity.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/info/VMInfoActivity.java index fac636a..c5b8229 100644 --- a/app/src/main/java/cn/classfun/droidvm/ui/vm/info/VMInfoActivity.java +++ b/app/src/main/java/cn/classfun/droidvm/ui/vm/info/VMInfoActivity.java @@ -1,5 +1,6 @@ package cn.classfun.droidvm.ui.vm.info; +import static cn.classfun.droidvm.lib.utils.StringUtils.fmt; import static android.widget.Toast.LENGTH_LONG; import static android.widget.Toast.LENGTH_SHORT; import static cn.classfun.droidvm.lib.store.enums.Enums.applyText; @@ -229,7 +230,7 @@ private String bootSummary() { var pinned = boot.getImageEntry(); var label = getString(R.string.edit_vm_kernel_source_image); if (pinned != null && pinned.title != null) - return label + " | " + pinned.title; + return fmt("%s | %s", label, pinned.title); return label; } return basename(boot.getKernel()); @@ -368,8 +369,7 @@ private void refreshPortForwards() { } catch (Exception e) { guest = "?"; } - lines.add(fwd.proto.toUpperCase() + " *:" + fwd.host - + " -> " + guest + ":" + fwd.guest); + lines.add(fmt("%s *:%s -> %s:%s", fwd.proto.toUpperCase(), fwd.host, guest, fwd.guest)); } } if (nic.isDhcp6LeaseEnabled() && vlan.isDhcp6Enabled()) { @@ -383,8 +383,7 @@ private void refreshPortForwards() { } catch (Exception e) { guest = "?"; } - lines.add(fwd.proto.toUpperCase() + " *:" + fwd.host - + " -> [" + guest + "]:" + fwd.guest); + lines.add(fmt("%s *:%s -> [%s]:%s", fwd.proto.toUpperCase(), fwd.host, guest, fwd.guest)); } } }); diff --git a/scripts/ci/check_string_concat.py b/scripts/ci/check_string_concat.py new file mode 100644 index 0000000..e6354fe --- /dev/null +++ b/scripts/ci/check_string_concat.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Flag Java string building done with `+` instead of the project's fmt() helper. + +Reports every `+` where exactly one operand is a string literal and the other is +a runtime expression (`"x=" + v`, `v + " done"`), which reads better as +`fmt("x=%s", v)`. Deliberately NOT flagged: + - `+` inside a string literal ("wlan+", "%s+", "\\s+") -- masked out first; + - literal + literal ("a" + "b") -- a compile-time constant, fmt adds nothing; + - `++` and numeric `+` with no string operand; + - a `+=` append with a SINGLE `+` (`s += "x" + y`) -- appending one piece to + an accumulator is clear intent fmt() would only obscure. But a `+=` chaining + two or more `+` (`s += "a" + b + "c"`) IS flagged: keep append complexity + low and reach for fmt() there. The count is per statement (to the next + `;`/`{`/`}`). + +A line is exempted with a trailing `// concat-ok` comment (for the rare case +where `+` genuinely reads better, e.g. a one-off constant split for width). + +Not a full parser: it masks comments and string/char literals, then inspects +the token on each side of every bare `+`. Good enough to catch the pattern the +reviewers keep flagging without drowning in false positives. + +Exit status is 0 when clean, 1 when any violation is found. Run from the repo +root, or pass the repo root as argv[1]. +""" +import os +import re +import subprocess +import sys + +SUPPRESS = "concat-ok" + + +def mask(src): + """Replace comments with spaces and each string/char literal with one STR + sentinel (\x01), preserving newlines so line numbers stay accurate.""" + out = [] + i, n = 0, len(src) + STR = "\x01" + while i < n: + c = src[i] + two = src[i:i + 2] + if two == "//": + j = src.find("\n", i) + j = n if j < 0 else j + out.append(" " * (j - i)) + i = j + elif two == "/*": + j = src.find("*/", i + 2) + j = n if j < 0 else j + 2 + out.append("".join(ch if ch == "\n" else " " for ch in src[i:j])) + i = j + elif c in "\"'": + j = i + 1 # consume literal, honour escapes + while j < n: + if src[j] == "\\": + j += 2 + continue + if src[j] == c: + j += 1 + break + j += 1 + out.append(STR) + i = j + else: + out.append(c) + i += 1 + return "".join(out) + + +# Significant tokens: a string literal (\x01), '+=' (marks an append statement), +# a value (identifier / number / closing bracket = end of a sub-expression), a +# '+', or a statement boundary (; { }) that scopes the '+=' exemption. +TOKEN = re.compile(r""" + \x01 # string literal + | \+\+ | \+= # compound operators (+= = append) + | \+ # the concatenation operator + | [A-Za-z_$][A-Za-z0-9_$]* | [0-9][\w.]* # identifier / number + | [)\]] # end of a sub-expression + | [;{}] # statement boundary +""", re.VERBOSE) + + +def _statement_hits(stmt, masked, suppressed): + """Violations within one statement's tokens. A '+=' append stays exempt only + while it is glance-obvious - at most ONE '+'. A second '+' (`s += "a"+b+"c"`) + is too complex: the exemption drops and every literal-joining '+' is reported, + steering it to fmt().""" + is_append = any(t == "+=" for t, _ in stmt) + plus_count = sum(1 for t, _ in stmt if t == "+") + if is_append and plus_count <= 1: # simple accumulator append: allow + return + for k, (tok, pos) in enumerate(stmt): + if tok != "+": + continue + left = stmt[k - 1][0] if k > 0 else None + right = stmt[k + 1][0] if k + 1 < len(stmt) else None + if (left == "\x01") != (right == "\x01"): # exactly one is a literal + line = masked.count("\n", 0, pos) + 1 + if line not in suppressed: + yield line + + +def violations_in(src, masked): + """Yield line numbers where a bare '+' joins a string literal and an expr. + Tokens are split into statements on ; { } so a '+=' append exemption cannot + leak past its statement and its '+' count is scoped correctly. Literal+literal + that spans lines stays within one statement, so it is still never flagged.""" + # suppression: any line carrying the marker is skipped. Checked against the + # original source, since masking blanks the comment the marker lives in. + suppressed = {i for i, ln in enumerate(src.split("\n"), 1) if SUPPRESS in ln} + stmt = [] + for m in TOKEN.finditer(masked): + tok = m.group(0) + if tok in (";", "{", "}"): + yield from _statement_hits(stmt, masked, suppressed) + stmt = [] + else: + stmt.append((tok, m.start())) + yield from _statement_hits(stmt, masked, suppressed) + + +def tracked_java(root): + out = subprocess.check_output(["git", "-C", root, "ls-files", "*.java"]) + return out.decode().split() + + +def main(): + root = os.path.abspath(sys.argv[1]) if len(sys.argv) > 1 else os.getcwd() + total = 0 + files = 0 + for rel in tracked_java(root): + p = os.path.join(root, rel) + if not os.path.isfile(p): + continue + with open(p, encoding="utf-8", errors="replace") as fh: + src = fh.read() + lines = sorted(set(violations_in(src, mask(src)))) + if not lines: + continue + files += 1 + total += len(lines) + raw = src.split("\n") + for ln in lines: + print(f"{rel}:{ln}: {raw[ln - 1].strip()[:100]}") + + if total: + print() + print(f"FAIL: {total} string concatenation(s) in {files} file(s); " + f"use fmt() instead of +.") + print(f"For a deliberate exception, add a trailing '// {SUPPRESS}' comment.") + return 1 + print("OK: no string-literal '+' concatenation.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 79afbddb13233cac43334952d5100e6124465d4c Mon Sep 17 00:00:00 2001 From: HuJK Date: Mon, 6 Jul 2026 16:55:31 +0800 Subject: [PATCH 09/10] acquire btn update --- .../ui/hugepage/AcquireContainerView.java | 171 ++++++++++++++++++ .../droidvm/ui/hugepage/HugePageActivity.java | 69 +++++-- .../ui/hugepage/HugePageProcessActivity.java | 6 + .../ui/hugepage/HugePageProcessAdapter.java | 5 +- app/src/main/res/layout/acquire_buttons.xml | 75 ++------ app/src/main/res/values-zh-rCN/strings.xml | 4 +- app/src/main/res/values-zh-rTW/strings.xml | 4 +- app/src/main/res/values/attrs.xml | 5 + app/src/main/res/values/strings.xml | 4 +- 9 files changed, 259 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/cn/classfun/droidvm/ui/hugepage/AcquireContainerView.java 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 new file mode 100644 index 0000000..1a56bf0 --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/ui/hugepage/AcquireContainerView.java @@ -0,0 +1,171 @@ +package cn.classfun.droidvm.ui.hugepage; + +import android.content.Context; +import android.content.res.TypedArray; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Paint; +import android.graphics.Path; +import android.graphics.RectF; +import android.graphics.drawable.Drawable; +import android.util.AttributeSet; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.graphics.ColorUtils; + +import com.google.android.material.color.MaterialColors; + +import cn.classfun.droidvm.R; + +/** + * The acquire-mode glyph drawn as a liquid-fill tank: an open-top box (no lid) + * with the download icon centred inside. The box fills with "water" up to a + * per-mode level - v1 shallow, v2 mid, v3 near-full - so the fill height, not a + * number badge, is what tells the three modes apart. + * + *

    The download glyph is split at the water line and drawn in opposite colours + * either side of it: {@code colorPrimary} above the surface (on the empty tank), + * {@code colorOnPrimary} for the submerged part (carved out of the water). The + * fill level is set per slot via {@code app:acv_fill} in {@code acquire_buttons.xml}. + * Greying/hiding is done by the host toggling the parent slot's alpha/visibility, + * so this view just draws itself at full strength. + */ +public final class AcquireContainerView extends View { + private final float density; + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path walls = new Path(); // open-top outline (stroked) + private final Path interior = new Path(); // rounded-bottom fill region (clip) + private final RectF box = new RectF(); + @Nullable + private final Drawable glyph; + + private final int colorOutline; + private final int colorWater; + private final int colorInterior; + private final int colorGlyphAbove; + private final int colorGlyphBelow; + + /** 0 = empty tank, 1 = full. Sets how high the water rises inside the box. */ + private float fill = 0.5f; + + public AcquireContainerView(Context context) { + this(context, null); + } + + public AcquireContainerView(Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + density = getResources().getDisplayMetrics().density; + Drawable d = AppCompatResources.getDrawable(context, R.drawable.ic_download); + glyph = d != null ? d.mutate() : null; // mutate: per-instance tint + int primary = MaterialColors.getColor(context, + androidx.appcompat.R.attr.colorPrimary, Color.BLUE); + int onPrimary = MaterialColors.getColor(context, + com.google.android.material.R.attr.colorOnPrimary, Color.WHITE); + colorOutline = primary; + colorWater = primary; + colorInterior = ColorUtils.setAlphaComponent(primary, 0x24); // faint tank tint + colorGlyphAbove = primary; // over the empty tank + colorGlyphBelow = onPrimary; // the opposite colour, under water + if (attrs != null) { + TypedArray a = context.obtainStyledAttributes( + attrs, R.styleable.AcquireContainerView); + fill = clamp01(a.getFloat(R.styleable.AcquireContainerView_acv_fill, fill)); + a.recycle(); + } + } + + /** Set the fill level (0..1) at runtime; clamped. */ + public void setFill(float value) { + float v = clamp01(value); + if (v != fill) { + fill = v; + invalidate(); + } + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { + int def = Math.round(44 * density); + setMeasuredDimension(resolveSize(def, widthMeasureSpec), + resolveSize(def, heightMeasureSpec)); + } + + @Override + protected void onDraw(@NonNull Canvas canvas) { + super.onDraw(canvas); + float w = getWidth(), h = getHeight(); + if (w <= 0 || h <= 0) return; + + float side = Math.min(w, h) * 0.62f; // square tank, centred + float left = (w - side) / 2f; + float top = (h - side) / 2f; + float right = left + side; + float bottom = top + side; + box.set(left, top, right, bottom); + float radius = side * 0.18f; // rounded bottom corners + float stroke = Math.max(1.5f * density, side * 0.07f); + float inset = stroke / 2f; // keep the stroke inside the box + + // Water surface, measured from the bottom up. Clamp inside the tank. + float waterY = Math.max(top, Math.min(bottom, bottom - fill * side)); + + // Interior region (square top, rounded bottom) clips the tint fills so + // the water follows the rounded corners. + interior.reset(); + interior.addRoundRect(box, + new float[]{0, 0, 0, 0, radius, radius, radius, radius}, + Path.Direction.CW); + + canvas.save(); + canvas.clipPath(interior); + paint.setStyle(Paint.Style.FILL); + paint.setColor(colorInterior); // faint tint over the whole tank + canvas.drawRect(left, top, right, bottom, paint); + paint.setColor(colorWater); // opaque water in the lower part + canvas.drawRect(left, waterY, right, bottom, paint); + canvas.restore(); + + // Open-top walls: left side, rounded bottom, right side - no top edge. + walls.reset(); + walls.moveTo(left + inset, top); + walls.lineTo(left + inset, bottom - radius); + walls.quadTo(left + inset, bottom - inset, left + radius, bottom - inset); + walls.lineTo(right - radius, bottom - inset); + walls.quadTo(right - inset, bottom - inset, right - inset, bottom - radius); + walls.lineTo(right - inset, top); + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(stroke); + paint.setStrokeCap(Paint.Cap.ROUND); + paint.setStrokeJoin(Paint.Join.ROUND); + paint.setColor(colorOutline); + canvas.drawPath(walls, paint); + + // Download glyph, centred and split at the water line: primary above the + // surface, the inverted on-primary colour carved out of the water below. + if (glyph != null) { + float g = side * 0.60f; + int gl = Math.round(left + (side - g) / 2f); + int gt = Math.round(top + (side - g) / 2f); + glyph.setBounds(gl, gt, Math.round(gl + g), Math.round(gt + g)); + + canvas.save(); + canvas.clipRect(left, top, right, waterY); + glyph.setTint(colorGlyphAbove); + glyph.draw(canvas); + canvas.restore(); + + canvas.save(); + canvas.clipRect(left, waterY, right, bottom); + glyph.setTint(colorGlyphBelow); + glyph.draw(canvas); + canvas.restore(); + } + } + + private static float clamp01(float v) { + return v < 0f ? 0f : Math.min(v, 1f); + } +} 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 bcd32a9..c859a6c 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 @@ -21,6 +21,7 @@ import android.view.View; import android.util.Log; import android.view.inputmethod.EditorInfo; +import android.widget.FrameLayout; import android.widget.TextView; import android.widget.Toast; @@ -32,6 +33,7 @@ import com.google.android.material.appbar.MaterialToolbar; import com.google.android.material.button.MaterialButton; import com.google.android.material.card.MaterialCardView; +import com.google.android.material.checkbox.MaterialCheckBox; import com.google.android.material.dialog.MaterialAlertDialogBuilder; import com.google.android.material.progressindicator.LinearProgressIndicator; @@ -57,6 +59,10 @@ public final class HugePageActivity extends AppCompatActivity { private static final String DISABLE_FILE = pathJoin(MAGISK_BASE, "disable"); private static final String CRASH_FILE = pathJoin(MAGISK_BASE, "crash"); private static final long PAGE_SIZE = 2L * 1024 * 1024; // 2MiB per page + // Shared app prefs (same store as Privacy/ApiManager) + the "don't ask again" + // flag for the acquire confirm dialog. + private static final String PREFS_NAME = "droidvm_prefs"; + private static final String KEY_SKIP_ACQUIRE_CONFIRM = "hugepage_acquire_skip_confirm"; private final Handler handler = new Handler(Looper.getMainLooper()); private final HugePageModel model = new HugePageModel(); private boolean resumed = false; @@ -164,12 +170,13 @@ private void initialize() { // while a run is in flight) interrupts it. Listeners are static -- the // slots aren't recycled -- and the idle/running visibility toggle is // driven by applyAcquireState(). - // Short-press runs it (gated to the pressable state); long-press opens the - // what-does-this-do dialog with a Run/Cancel choice (always available, even - // when the button is greyed - the buttons stay enabled for that). - btnAcquireV1.setOnClickListener(v -> { if (acquireEnabled && !mainAcquiring) startAcquire(1); }); - btnAcquireV2.setOnClickListener(v -> { if (acquireEnabled && !mainAcquiring) startAcquire(2); }); - btnAcquireV3.setOnClickListener(v -> { if (acquireEnabled && !mainAcquiring) startAcquire(3); }); + // Short-press opens the what-does-this-do dialog (or, once the user ticks + // "don't ask again", runs straight away) - gated to the pressable state. + // Long-press always opens the dialog, even when the button is greyed (the + // buttons stay enabled for that), so the prompt can be re-summoned any time. + btnAcquireV1.setOnClickListener(v -> { if (acquireEnabled && !mainAcquiring) confirmAcquire(this, 1, () -> startAcquire(1)); }); + btnAcquireV2.setOnClickListener(v -> { if (acquireEnabled && !mainAcquiring) confirmAcquire(this, 2, () -> startAcquire(2)); }); + btnAcquireV3.setOnClickListener(v -> { if (acquireEnabled && !mainAcquiring) confirmAcquire(this, 3, () -> startAcquire(3)); }); btnAcquireV1.setOnLongClickListener(v -> { showAcquireInfo(this, 1, () -> startAcquire(1)); return true; }); btnAcquireV2.setOnLongClickListener(v -> { showAcquireInfo(this, 2, () -> startAcquire(2)); return true; }); btnAcquireV3.setOnLongClickListener(v -> { showAcquireInfo(this, 3, () -> startAcquire(3)); return true; }); @@ -205,26 +212,58 @@ private void applyAcquireSlot(View btn, View spinner, int mode) { spinner.setVisibility(spin ? VISIBLE : GONE); } - /** Long-press info: what the acquire mode does, with a Run/Cancel choice. */ + /** + * Short-press an acquire button: run straight away if the user ticked "don't + * ask again" in a past prompt, otherwise show the confirm dialog. Long-press + * bypasses this and always shows the dialog (see {@link #showAcquireInfo}). + */ + static void confirmAcquire(@NonNull Context ctx, int mode, @NonNull Runnable onRun) { + if (skipAcquireConfirm(ctx)) onRun.run(); + else showAcquireInfo(ctx, mode, onRun); + } + + /** + * Explain what the acquire mode does, with a Run/Cancel choice and a "don't ask + * again" checkbox. The checkbox seeds from and (on any dismiss) writes back the + * skip preference, so it doubles as the way to re-enable the prompt after opting + * out; Run additionally starts the acquire. Shared by both hugepage screens, for + * short-press (via {@link #confirmAcquire}) and long-press alike. The title is + * always "Acquire huge pages" - the mode is conveyed by the explanation text. + */ static void showAcquireInfo(@NonNull Context ctx, int mode, @NonNull Runnable onRun) { - int modeLabel = mode == 2 ? R.string.hugepage_proc_acquire_v2 - : mode == 3 ? R.string.hugepage_proc_acquire_v3 - : R.string.hugepage_proc_acquire_v1; int msg = mode == 2 ? R.string.hugepage_acquire_v2_explain : mode == 3 ? R.string.hugepage_acquire_v3_explain : R.string.hugepage_acquire_v1_explain; - // Title = "Acquire huge pages" + the mode badge, e.g. "Acquire huge pages v1". - String title = fmt("%s %s", - ctx.getString(R.string.hugepage_acquire_pages_title), - ctx.getString(modeLabel)); + // "Don't ask again", indented to line up with the dialog's message text. + float density = ctx.getResources().getDisplayMetrics().density; + var dontAsk = new MaterialCheckBox(ctx); + dontAsk.setText(R.string.hugepage_acquire_dont_ask); + dontAsk.setChecked(skipAcquireConfirm(ctx)); + var holder = new FrameLayout(ctx); + int padH = Math.round(24 * density); + holder.setPaddingRelative(padH, Math.round(4 * density), padH, 0); + holder.addView(dontAsk); new MaterialAlertDialogBuilder(ctx) - .setTitle(title) + .setTitle(R.string.hugepage_acquire_pages_title) // no v1/v2/v3 suffix .setMessage(msg) + .setView(holder) .setPositiveButton(R.string.hugepage_acquire_run, (d, w) -> onRun.run()) .setNegativeButton(android.R.string.cancel, null) + .setOnDismissListener(d -> setSkipAcquireConfirm(ctx, dontAsk.isChecked())) .show(); } + /** True once the user opted out of the acquire prompt (short-press runs directly). */ + static boolean skipAcquireConfirm(@NonNull Context ctx) { + return ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getBoolean(KEY_SKIP_ACQUIRE_CONFIRM, false); + } + + private static void setSkipAcquireConfirm(@NonNull Context ctx, boolean skip) { + ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit().putBoolean(KEY_SKIP_ACQUIRE_CONFIRM, skip).apply(); + } + /** * Start filling the pool with acquire algorithm {@code mode} (see * {@link HugePageModel#acquire}). Optimistically shows the spinners, fires the 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 1bdce49..20a4799 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 @@ -439,6 +439,12 @@ public void onAcquire(int mode) { }, "hugepage-acquire").start(); } + /** Short-press an acquire button: confirm (unless the user opted out), then run. */ + @Override + public void onAcquireConfirm(int mode) { + HugePageActivity.confirmAcquire(this, mode, () -> onAcquire(mode)); + } + /** Long-press an acquire button: explain the mode, with a Run/Cancel choice. */ @Override public void onAcquireInfo(int mode) { 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 1f41f3f..39f1388 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 @@ -36,6 +36,9 @@ public interface Listener { * 3 = migrate + per-block evict to zram. */ void onAcquire(int mode); + /** Short-press: confirm via the info dialog (unless the user opted out), then run. */ + void onAcquireConfirm(int mode); + /** Long-press: show the what-does-this-mode-do dialog (with a Run choice). */ void onAcquireInfo(int mode); @@ -183,7 +186,7 @@ private void bindAcquireSlot(View btn, View spinner, int mode) { // Left enabled so long-press always opens the info dialog; short-press is // gated on !acquiring and the icon greys via alpha while a run is in flight. btn.setAlpha(acquiring ? 0.38f : 1f); - btn.setOnClickListener(v -> { if (!acquiring) listener.onAcquire(mode); }); + btn.setOnClickListener(v -> { if (!acquiring) listener.onAcquireConfirm(mode); }); btn.setOnLongClickListener(v -> { listener.onAcquireInfo(mode); return true; }); spinner.setVisibility(spin ? VISIBLE : GONE); spinner.setOnClickListener(spin ? v -> listener.onStopAcquire() : null); diff --git a/app/src/main/res/layout/acquire_buttons.xml b/app/src/main/res/layout/acquire_buttons.xml index fb26d47..6125851 100644 --- a/app/src/main/res/layout/acquire_buttons.xml +++ b/app/src/main/res/layout/acquire_buttons.xml @@ -1,8 +1,10 @@ + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64c71f4..16bb6b1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -962,13 +962,11 @@ Failed to trigger acquire Save Acquire - v1 - v2 - v3 Acquire v1 (alloc_contig_pages scan) Acquire v2 (migrate + system reclaim) Acquire v3 (migrate + per-block evict to zram) Run + Don\'t ask again Uses alloc_contig_pages to collect huge pages Uses try_to_free_mem_cgroup_pages to push other apps into swap/zram\nthen alloc_contig_range to collect huge pages Uses folio_isolate_lru + reclaim_pages to precisely push pages into swap/zram\nthen alloc_contig_range to collect huge pages From 61f4abbb9556759948e33087cf7db9d7c04488fd Mon Sep 17 00:00:00 2001 From: HuJK Date: Tue, 7 Jul 2026 02:47:16 +0800 Subject: [PATCH 10/10] hugepage: show acquire_stop_reason in the acquire-done bubble The gh_hugepage_reserve module now exports acquire_stop_reason in refill_stat (why the last user-triggered acquire stopped). Thread it through Snapshot as a pass-through display string (like state/totalServed) and append it to the acquire-done Toast, so the user sees the reason in the bubble instead of having to read dmesg. Kernel free text; appended raw (localization is a follow-up). Co-Authored-By: Claude Fable 5 --- .../droidvm/ui/hugepage/HugePageActivity.java | 11 ++++++++--- .../classfun/droidvm/ui/hugepage/HugePageModel.java | 10 +++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) 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 c859a6c..f562b72 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 @@ -459,13 +459,18 @@ poolAvail, fmt("%s\n%s", getString(R.string.hugepage_bar_available), SizeUtils.f if (wasAcquiring && !acquiring) { long got = snap.free + snap.lent; long want = snap.targetIdeal; - Toast.makeText(this, got >= want + String msg = got >= want ? getString(R.string.hugepage_proc_acquire_full, SizeUtils.formatSize(want * PAGE_SIZE)) : getString(R.string.hugepage_proc_acquire_partial, SizeUtils.formatSize(got * PAGE_SIZE), - SizeUtils.formatSize(want * PAGE_SIZE)), - Toast.LENGTH_LONG).show(); + SizeUtils.formatSize(want * PAGE_SIZE)); + // Append why the acquire stopped (kernel free text from refill_stat's + // acquire_stop_reason), so the user sees the reason in the bubble. + String reason = snap.acquireStopReason; + if (!reason.isEmpty() && !"-".equals(reason)) + msg += "\n" + reason; + Toast.makeText(this, msg, Toast.LENGTH_LONG).show(); } wasAcquiring = acquiring; if (mainAcquiring != acquiring || mainAcquireMode != mode) { 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 f26d7e9..8d9a92e 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 @@ -182,12 +182,14 @@ static final class Snapshot { @NonNull final String totalServed; @NonNull final String totalRefilled; @NonNull final String activeVms; + @NonNull final String acquireStopReason; // why the last acquire stopped ("-" if unreported) private Snapshot(boolean installed, boolean loaded, boolean statsOk, boolean bootEnabled, long targetIdeal, long built, long free, long lent, long deficit, boolean acquiring, int acquireMode, boolean hasPoolWant, boolean softDisabled, @NonNull String state, @NonNull String totalServed, - @NonNull String totalRefilled, @NonNull String activeVms) { + @NonNull String totalRefilled, @NonNull String activeVms, + @NonNull String acquireStopReason) { this.installed = installed; this.loaded = loaded; this.statsOk = statsOk; @@ -205,6 +207,7 @@ private Snapshot(boolean installed, boolean loaded, boolean statsOk, boolean boo this.totalServed = totalServed; this.totalRefilled = totalRefilled; this.activeVms = activeVms; + this.acquireStopReason = acquireStopReason; } } @@ -223,7 +226,7 @@ Snapshot state() { boolean loaded = existsSticky(SYSFS_BASE); if (!loaded) { return new Snapshot(installed, false, false, bootEnabled, - 0, 0, 0, 0, 0, false, -1, false, false, "-", "-", "-", "-"); + 0, 0, 0, 0, 0, false, -1, false, false, "-", "-", "-", "-", "-"); } var s = parseProp(safeRead(pathJoin(SYSFS_PARAMS, "refill_stat"))); boolean statsOk = !s.isEmpty(); // empty == the read failed transiently @@ -242,7 +245,8 @@ Snapshot state() { return new Snapshot(installed, true, statsOk, bootEnabled, want, built, free, lent, deficit, acquiring, mode, hasWant, softDisabled, s.getOrDefault("state", "-"), s.getOrDefault("total_served", "-"), - s.getOrDefault("total_refilled", "-"), s.getOrDefault("active_vms", "-")); + s.getOrDefault("total_refilled", "-"), s.getOrDefault("active_vms", "-"), + s.getOrDefault("acquire_stop_reason", "-")); } /**