diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..78e7157 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,35 @@ +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 + + - name: Use fmt() instead of string concatenation + run: python3 scripts/ci/check_string_concat.py 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 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/DefaultRouterWatcher.java b/app/src/main/java/cn/classfun/droidvm/daemon/network/backend/DefaultRouterWatcher.java index b17f26b..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; @@ -24,6 +25,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"; @@ -192,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 @@ -213,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( @@ -253,6 +255,64 @@ 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. + * 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 = 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(fmt("echo 1 > %s", 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() { 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/lib/utils/Try.java b/app/src/main/java/cn/classfun/droidvm/lib/utils/Try.java new file mode 100644 index 0000000..ed195bb --- /dev/null +++ b/app/src/main/java/cn/classfun/droidvm/lib/utils/Try.java @@ -0,0 +1,48 @@ +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 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) + */ +public final class Try { + /** The implementation this attempt used. */ + public final I impl; + /** The value produced on success; {@code null} on failure (or for {@link Void}). */ + @Nullable + public final V value; + /** The failure reason; {@code null} on success. */ + @Nullable + public final String error; + + private Try(I impl, @Nullable V value, @Nullable String error) { + this.impl = impl; + this.value = value; + this.error = error; + } + + public static Try ok(I impl, @Nullable V value) { + return new Try<>(impl, value, null); + } + + 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). */ + public boolean ok() { + return error == null; + } +} 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 d5a6dd7..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 @@ -5,11 +5,12 @@ 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.fmt; import static cn.classfun.droidvm.lib.utils.StringUtils.pathJoin; import static cn.classfun.droidvm.lib.utils.ThreadUtils.runOnPool; +import android.content.Context; import android.content.Intent; import android.content.res.ColorStateList; import android.graphics.Color; @@ -20,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; @@ -31,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; @@ -51,14 +54,15 @@ 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"); 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; @@ -75,7 +79,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 +124,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 +160,156 @@ 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 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; }); + 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); + } + + /** + * 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 msg = mode == 2 ? R.string.hugepage_acquire_v2_explain + : mode == 3 ? R.string.hugepage_acquire_v3_explain + : R.string.hugepage_acquire_v1_explain; + // "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(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 + * 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 +336,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 +371,65 @@ 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] = 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 + // 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, fmt("%s\n%s", getString(R.string.hugepage_bar_available), SizeUtils.formatSize(poolAvail * PAGE_SIZE)), + HugePageColor.pending(this), deficit, + 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)); rowStatTotalServed.setValue(null); @@ -353,85 +445,107 @@ 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; + 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)); + // 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) { + 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 +556,7 @@ private void doSoftDisable() { */ private void interruptAcquire() { runOnPool(() -> { - run("echo 0 > %s/acquire", SYSFS_PARAMS); + model.stopAcquire(); runOnUiThread(this::refreshStatus); }); } @@ -450,15 +564,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 +580,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 +657,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 +679,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 +698,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..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 @@ -2,7 +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; @@ -18,131 +21,684 @@ 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; +import cn.classfun.droidvm.lib.utils.Try; /** - * 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 "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 + * 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; + @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 acquireStopReason) { + 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; + this.acquireStopReason = acquireStopReason; } } - @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", "-"), + s.getOrDefault("acquire_stop_reason", "-")); } /** - * 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 "already enabled". + 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(fmt("v%d", 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 = 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 + // 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, 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, + () -> 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", escapedString(KO_PATH)) + : run("insmod %s %s", escapedString(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 = fmt("exit %d", r.getCode()); + return fmt("%s: %s", 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, fmt("KO read: %s", 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 +709,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 +725,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 +835,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..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 @@ -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,32 @@ 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(); } + /** 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) { + 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..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 @@ -32,7 +32,15 @@ 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); + + /** 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); void onStopAcquire(); } @@ -41,16 +49,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 +119,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 +178,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.onAcquireConfirm(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 +214,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 +230,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/main/vm/MainVMFragment.java b/app/src/main/java/cn/classfun/droidvm/ui/main/vm/MainVMFragment.java index 53c084c..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.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/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/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/console/VMConsoleRouter.java b/app/src/main/java/cn/classfun/droidvm/ui/vm/console/VMConsoleRouter.java new file mode 100644 index 0000000..a8a0146 --- /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/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/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); 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..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 @@ -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); } } 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/app/src/main/res/layout/acquire_buttons.xml b/app/src/main/res/layout/acquire_buttons.xml new file mode 100644 index 0000000..6125851 --- /dev/null +++ b/app/src/main/res/layout/acquire_buttons.xml @@ -0,0 +1,154 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/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 + 获取 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..5184929 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -111,6 +111,10 @@ 虛擬機器"%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日誌: 無可用日誌。 @@ -922,11 +926,21 @@ 獲取 獲取大頁中… 獲取完成 + 獲取 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/attrs.xml b/app/src/main/res/values/attrs.xml index af54d63..e431d4c 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -1,5 +1,10 @@ + + + + + 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/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7692a34..16bb6b1 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. @@ -962,16 +962,26 @@ Failed to trigger acquire Save Acquire - Acquiring huge pages… + 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 + 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… + 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) @@ -994,7 +1004,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 @@ -1008,11 +1018,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. @@ -1020,8 +1030,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 new file mode 100644 index 0000000..503a8e6 --- /dev/null +++ b/scripts/ci/ascii_allow.txt @@ -0,0 +1,25 @@ +# 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. + +# 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 + +# 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_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()) 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())