Add support for Android activities #75

Open
albert wants to merge 85 commits from u/albert/2/android-activity into main
Owner

Fixes #2

Relies on #71's Android deps installer

Android activities via Waydroid (#2)

Adds type = "android" entries: the launcher can run Android apps (Khan Academy,
Duolingo, M365, …) inside a Waydroid container, with the same availability
windows, time limits, HUD, and kiosk containment as every other activity kind.

86 commits, 95 files, ~9k lines — of which ~4.2k are Rust across 37 files and the
rest is the Device Policy Controller app, provisioning scripts, and 23 history
documents. It is large because Waydroid turned out to be a demanding host: most
of the diff is the difference between "an Android window appears" and "a child
can use it without noticing it isn't native."

What a user sees

An Android entry looks and behaves like any other tile. Underneath:

  • Android tiles stay hidden until Waydroid has actually booted, so a tap can
    never land on a boot animation.
  • The container is prebooted at daemon start, so the child's first open is
    ~7s rather than ~80s.
  • The launched app is fullscreen under the HUD, which occludes Android's own
    status bar and adds a back button in place of the caption bar the child
    must not reach.
  • Kiosk lock-in is configurable: lock_mode = "statusbar" (default) disables
    the notification shade and nav-bar home/recents; "locktask" pins the app in
    Android Lock Task Mode via a Device Owner DPC; "off" disables both.
  • Media volume, nav bar, and display density are normalized so shepherd owns the
    full volume range and the UI isn't half-size on a HiDPI panel.

Configuration

[[entries]]
id = "android-calculator"
label = "Calculator"
icon = "accessories-calculator"

[entries.kind]
type = "android"
package_name = "com.android.calculator2"
[service.waydroid]
# preboot = true            # default: on iff any android entry exists
multi_window = true         # per-app toplevels (app_id "waydroid.<pkg>")
suspend_when_idle = true
boot_ready_timeout_seconds = 60
lock_mode = "statusbar"     # "statusbar" | "locktask" | "off"

Setup is shepherd-admin apps install android <user> — see docs/INSTALL.md,
which covers the Waydroid engine, the GApps image, libndk ARM translation, DPC
device-owner provisioning, and Play certification.

Architecture

crates/shepherd-host-linuxspawn_android / stop_android in the
adapter, plus a waydroid module wrapping the CLI. Sessions are tracked by their
Wayland toplevel (waydroid.<pkg>, or the single Waydroid full-UI surface
under locktask); exit is the toplevel going away, so there is no pid proxy.

crates/shepherd-waydroid-helper — a small privileged helper behind polkit
(org.shepherd.waydroid.helper, group shepherd-waydroid) for the operations
that need root: force-stop, preboot, lock-down, pin/unlock,
boot-completed, maximize, back, max-volume, scale-density,
display-size, is-running. The only caller-controlled value anywhere is a
package name, validated by a shared shepherd-util predicate and passed as a
single argv element.

dpc-waydroid/ — a minimal Device Policy Controller (Device Owner) for Lock
Task Mode. Built by a plain SDK pipeline, signed with the shared org key, shipped
inside the .deb, and installed by shepherd-admin apps install android.

Readiness gating reuses the Steam mechanism from #76:
HostEvent::KindReadinessChanged hides the kind until a launch would succeed.

The parts worth reviewing carefully

Fractional display scale. Waydroid's hwcomposer reads the compositor's output
scale once, at session boot, and latches it. shepherd therefore boots the
session with outputs held at scale 1 and expresses the intended zoom as Android
density. On a stock Waydroid, the scale restore on activity exit permanently
halves every surface it presents afterwards — so the fast reopen path requires
a patched hwcomposer
, installable from #119 (upstream PR pending). This is now
a documented prerequisite, reported by the admin command, and recorded at the one
place in the code that depends on it. docs/ai/waydroid-fractional-scale-upstream.md
is the standalone bug brief.

Preboot is the most delicate function here. It holds a restart guard across
the boot (a concurrent docking repin would otherwise stop the session mid-boot),
holds scale 1 only until the hwcomposer signals it has read the scale, verifies
afterwards that Android actually took the panel's physical mode, and re-applies
its property pins after the session exists — because waydroid prop set reaches
the property service through the session and exits 0 when there is none.

Lock Task suppresses per-app toplevels. The locktask path therefore presents
the single full-UI surface, parked off-screen until the pin lands so the child
never sees the Android home screen, and parked again on stop rather than
destroyed — that surface is Android's display connection, and killing it takes
surfaceflinger and zygote down with it.

Wire changes

EntryKindTag::Android and EntryKind::Android { package_name, args }, plus
ServiceStateSnapshot.startup_busy (shells cover the screen while a startup step
disrupts it). Generated Kotlin/TS outputs regenerated; docs/rpc-schema.json
updated. The drift guard now runs over the whole workspace, and a new job
typechecks the hand-written web-UI types that codegen doesn't own.

Testing

  • Unit — 46 test binaries, including preboot/gating logic, the helper's
    argument validation, and the PrebootGate invariants.
  • In-kiosk integration (scripts/integration-tests/test-waydroid.sh, five
    #[ignore] tests against real Waydroid — LineageOS 20 / Android 13 GAPPS,
    Waydroid 1.6.2): preboot, cold-container prop correction, launch/stop, the
    locktask DPC path, and shutdown teardown. All five pass on this branch head.
  • Headless UI — the loading screen, gating, and HUD behavior verified through
    the headless-dev harness with screenshots.

CI is green: cargo test --workspace --all-targets, clippy -D warnings, fmt,
shellcheck, arch-neutrality, .deb smoke build, web-UI typecheck, and the
companion/DPC/media Android jobs.

Known limitations

  • Nvidia GPUs are unsupported by Waydroid, so Android entries won't work
    there. amd64 needs libndk for ARM-only apps; arm64 runs them natively.
  • The patched hwcomposer is required on fractionally-scaled panels (#119).
  • GApps provisioning is partly interactive — Google sign-in and device
    certification can't be automated; the admin command guides and reports.
  • The DPC signs with the shared org release key. Because a device-owner app only
    updates from the same key, that key is effectively un-rotatable once any
    device is provisioned. Deliberate, and called out in release.yml.

Reading order

The design history is in docs/ai/history/, roughly chronological:

  1. 2026-06-28 001 android-activity-kind-scoping.md — why Waydroid, what the kind is
  2. 2026-06-28 004 android-phase2-runtime-slice.md — spawn/stop/window tracking
  3. 2026-06-28 006 android-force-stop-helper-and-preboot.md — the privileged seam
  4. 2026-06-28 009 android-dpc-lock-task.md + 2026-07-12 002 default-managed-android-config.md — lock-in and the DPC
  5. 2026-07-17 001 waydroid-fractional-scale-diagnosis.md + 2026-07-29 003 android-first-open-native-scale-preboot.md — the scale saga
  6. 2026-07-30 002/003/005/006 — loading screen, DPC packaging, the integration-suite re-run, and the prop-pin fix
Fixes #2 Relies on #71's Android deps installer # Android activities via Waydroid (#2) Adds `type = "android"` entries: the launcher can run Android apps (Khan Academy, Duolingo, M365, …) inside a Waydroid container, with the same availability windows, time limits, HUD, and kiosk containment as every other activity kind. 86 commits, 95 files, ~9k lines — of which ~4.2k are Rust across 37 files and the rest is the Device Policy Controller app, provisioning scripts, and 23 history documents. It is large because Waydroid turned out to be a demanding host: most of the diff is the difference between "an Android window appears" and "a child can use it without noticing it isn't native." ## What a user sees An Android entry looks and behaves like any other tile. Underneath: - Android tiles stay **hidden until Waydroid has actually booted**, so a tap can never land on a boot animation. - The container is **prebooted at daemon start**, so the child's first open is ~7s rather than ~80s. - The launched app is **fullscreen under the HUD**, which occludes Android's own status bar and adds a **back button** in place of the caption bar the child must not reach. - **Kiosk lock-in** is configurable: `lock_mode = "statusbar"` (default) disables the notification shade and nav-bar home/recents; `"locktask"` pins the app in Android Lock Task Mode via a Device Owner DPC; `"off"` disables both. - Media volume, nav bar, and display density are normalized so shepherd owns the full volume range and the UI isn't half-size on a HiDPI panel. ## Configuration ```toml [[entries]] id = "android-calculator" label = "Calculator" icon = "accessories-calculator" [entries.kind] type = "android" package_name = "com.android.calculator2" ``` ```toml [service.waydroid] # preboot = true # default: on iff any android entry exists multi_window = true # per-app toplevels (app_id "waydroid.<pkg>") suspend_when_idle = true boot_ready_timeout_seconds = 60 lock_mode = "statusbar" # "statusbar" | "locktask" | "off" ``` Setup is `shepherd-admin apps install android <user>` — see `docs/INSTALL.md`, which covers the Waydroid engine, the GApps image, libndk ARM translation, DPC device-owner provisioning, and Play certification. ## Architecture **`crates/shepherd-host-linux`** — `spawn_android` / `stop_android` in the adapter, plus a `waydroid` module wrapping the CLI. Sessions are tracked by their Wayland toplevel (`waydroid.<pkg>`, or the single `Waydroid` full-UI surface under locktask); exit is the toplevel going away, so there is no pid proxy. **`crates/shepherd-waydroid-helper`** — a small privileged helper behind polkit (`org.shepherd.waydroid.helper`, group `shepherd-waydroid`) for the operations that need root: `force-stop`, `preboot`, `lock-down`, `pin`/`unlock`, `boot-completed`, `maximize`, `back`, `max-volume`, `scale-density`, `display-size`, `is-running`. The only caller-controlled value anywhere is a package name, validated by a shared `shepherd-util` predicate and passed as a single argv element. **`dpc-waydroid/`** — a minimal Device Policy Controller (Device Owner) for Lock Task Mode. Built by a plain SDK pipeline, signed with the shared org key, shipped inside the `.deb`, and installed by `shepherd-admin apps install android`. **Readiness gating** reuses the Steam mechanism from #76: `HostEvent::KindReadinessChanged` hides the kind until a launch would succeed. ## The parts worth reviewing carefully **Fractional display scale.** Waydroid's hwcomposer reads the compositor's output scale *once, at session boot*, and latches it. shepherd therefore boots the session with outputs held at scale 1 and expresses the intended zoom as Android density. On a stock Waydroid, the scale restore on activity exit permanently halves every surface it presents afterwards — so the fast reopen path **requires a patched hwcomposer**, installable from #119 (upstream PR pending). This is now a documented prerequisite, reported by the admin command, and recorded at the one place in the code that depends on it. `docs/ai/waydroid-fractional-scale-upstream.md` is the standalone bug brief. **Preboot is the most delicate function here.** It holds a restart guard across the boot (a concurrent docking repin would otherwise stop the session mid-boot), holds scale 1 only until the hwcomposer signals it has read the scale, verifies afterwards that Android actually took the panel's physical mode, and re-applies its property pins after the session exists — because `waydroid prop set` reaches the property service *through the session* and exits 0 when there is none. **Lock Task suppresses per-app toplevels.** The locktask path therefore presents the single full-UI surface, parked off-screen until the pin lands so the child never sees the Android home screen, and parked again on stop rather than destroyed — that surface is Android's display connection, and killing it takes surfaceflinger and zygote down with it. ## Wire changes `EntryKindTag::Android` and `EntryKind::Android { package_name, args }`, plus `ServiceStateSnapshot.startup_busy` (shells cover the screen while a startup step disrupts it). Generated Kotlin/TS outputs regenerated; `docs/rpc-schema.json` updated. The drift guard now runs over the whole workspace, and a new job typechecks the hand-written web-UI types that codegen doesn't own. ## Testing - **Unit** — 46 test binaries, including preboot/gating logic, the helper's argument validation, and the `PrebootGate` invariants. - **In-kiosk integration** (`scripts/integration-tests/test-waydroid.sh`, five `#[ignore]` tests against real Waydroid — LineageOS 20 / Android 13 GAPPS, Waydroid 1.6.2): preboot, cold-container prop correction, launch/stop, the locktask DPC path, and shutdown teardown. All five pass on this branch head. - **Headless UI** — the loading screen, gating, and HUD behavior verified through the `headless-dev` harness with screenshots. CI is green: `cargo test --workspace --all-targets`, `clippy -D warnings`, `fmt`, shellcheck, arch-neutrality, `.deb` smoke build, web-UI typecheck, and the companion/DPC/media Android jobs. ## Known limitations - **Nvidia GPUs are unsupported by Waydroid**, so Android entries won't work there. amd64 needs libndk for ARM-only apps; arm64 runs them natively. - **The patched hwcomposer is required** on fractionally-scaled panels (#119). - **GApps provisioning is partly interactive** — Google sign-in and device certification can't be automated; the admin command guides and reports. - The DPC signs with the shared org release key. Because a device-owner app only updates from the same key, that key is effectively **un-rotatable** once any device is provisioned. Deliberate, and called out in `release.yml`. ## Reading order The design history is in `docs/ai/history/`, roughly chronological: 1. `2026-06-28 001 android-activity-kind-scoping.md` — why Waydroid, what the kind is 2. `2026-06-28 004 android-phase2-runtime-slice.md` — spawn/stop/window tracking 3. `2026-06-28 006 android-force-stop-helper-and-preboot.md` — the privileged seam 4. `2026-06-28 009 android-dpc-lock-task.md` + `2026-07-12 002 default-managed-android-config.md` — lock-in and the DPC 5. `2026-07-17 001 waydroid-fractional-scale-diagnosis.md` + `2026-07-29 003 android-first-open-native-scale-preboot.md` — the scale saga 6. `2026-07-30 002/003/005/006` — loading screen, DPC packaging, the integration-suite re-run, and the prop-pin fix
Scopes issue #2 (Android apps via Waydroid as an entry kind): the
mechanical config/plumbing half (modeled on Steam), the runtime
redesign forced by Waydroid having no host PID and a single global
container, the provisioning burden (GAPPS/certification/ARM/lock-in),
per-app feasibility, open questions, and a phased plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
Stood up Waydroid 1.6.2 on the actual box (Ubuntu 26.04 "resolute" VM,
not the 25.10 the docs assumed) and validated every runtime mechanic the
Android-kind design depends on:

- Multi-window toplevel app_id is `waydroid.<package>` (confirmed
  `waydroid.com.android.calculator2`).
- The production `for_window [app_id="^waydroid\..*"] fullscreen enable`
  rule fires (fullscreen_mode=1, full output) under a headless nested sway.
- `am force-stop` destroys the toplevel within ~1s -> window-destroy is a
  clean HostEvent::Exited trigger (design B1; pid-proxy not needed).
- binder_linux loads with no DKMS; `resolute` repo has built packages.

Key gotchas captured for implementation: `waydroid shell` needs root,
needs `--details-to-stdout` for stdout, log-based readiness, multi-window
requires a session restart. Measured warm boot ~10s, idle RAM ~0.8GB.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
Adds `EntryKind::Android { package_name, args }` mirroring the Steam/Flatpak
external-app-manager kinds, threaded through the canonical enums, validation
(with an Android-package-name check that rejects shell metacharacters),
policy conversion, icon/tile fallbacks, and the validate-config summary.

Also adds a service-level `[service.waydroid]` block (preboot / multi_window /
suspend_when_idle / boot_ready_timeout) since Waydroid runs one global
container shared by all Android entries, plus a config.example.toml entry.

Config-layer only: the host adapter returns UnsupportedKind and linux_full()
does not advertise the capability yet, so launches are gracefully rejected
until the Phase 2 runtime wiring. Tests + clippy -D warnings + fmt all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
Implements the runtime vertical slice for the Android entry kind. Since a
Waydroid app has no host process (it runs inside the Android container), the
whole lifecycle is built on its Wayland toplevel rather than a pid:

- New waydroid.rs CLI wrapper: session_running, launch_app (session user),
  best-effort force_stop, and pure helpers (app_id "waydroid.<pkg>", argv
  builders, status parsing) with unit tests.
- New HostHandlePayload::Android { package_name } (no pid).
- adapter spawn() dispatches Android early to spawn_android: launch, wait for
  the waydroid.<pkg> toplevel (WindowReady), and arm a window-watch task that
  emits Exited exactly once when the toplevel disappears.
- adapter stop() closes the toplevel via sway (user-level; the authoritative
  session end) plus best-effort force_stop; the watch task emits Exited.
- linux_full() now advertises EntryKindTag::Android.

Design validated on the bench: closing the toplevel ends the session without
root; am force-stop (root) only reclaims the cached process, so it is
best-effort until a pkexec seam lands. Preboot and privileged force-stop are
clearly-scoped follow-ups (see the Phase 2 history doc). fmt + clippy
-D warnings + tests all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
Sketches a pkexec + polkit seam (modeled on shepherd-firewall-helper) so the
unprivileged shepherdd can reclaim a cached Android process via root
`waydroid shell am force-stop`. Covers the trust boundary, the strict
package-name re-validation as the injection defense, the helper/polkit/caller
components, install/CI wiring, and open questions for review. Design only;
no implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
shepherdd runs unprivileged, so the two root-requiring Waydroid operations now
go through a small pkexec helper (modeled on shepherd-firewall-helper):

- New crate shepherd-waydroid-helper with two fixed actions: `force-stop
  --package <pkg>` (re-validates the package, then `waydroid shell am
  force-stop`) and `preboot` (`systemctl start waydroid-container`, hardcoded
  unit). std-only but for the shared validator.
- Lifted is_valid_android_package into shepherd-util as the single source of
  truth; shepherd-config and the helper both use it.
- polkit action org.shepherd.waydroid.helper gated on the binary path, granted
  password-less to the dedicated `shepherd-waydroid` group; install_waydroid +
  a `waydroid` install subcommand (opt-in, not in `install all`) + dev script +
  INSTALL.md section.
- waydroid.rs force_stop now invokes the helper via pkexec; added session/prop
  helpers and start_session_and_wait. adapter configure_waydroid +
  preboot_waydroid (container -> session -> multi-window -> idle-suspend);
  shepherdd preboots iff [service.waydroid] preboot is set, else iff any
  android entry exists.

Validated live as root: preboot starts the container, force-stop kills a
launched app; install places files + registers the polkit action (pkaction).
The runtime pkexec->polkit grant needs a real login session and is unverified
here. fmt + clippy -D warnings + tests all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
Verifies the full Android runtime against a live Sway + Waydroid:

- waydroid_real.rs: an #[ignore] adapter integration test (like firewall_real)
  that spawns EntryKind::Android, asserts WindowReady + the waydroid.<pkg>
  toplevel is on screen, stops it, and asserts Exited + the window is gone.
  Skips cleanly when prerequisites are missing.
- test-waydroid.sh: orchestrator that stands up a nested headless sway + a
  Waydroid session and runs the test via `sudo -u` so the shepherd-waydroid
  group is effective (exercising force_stop -> pkexec -> helper too).

Passed on the box: launch -> window present/fullscreen -> stop -> window gone
-> Exited. Also confirmed the pkexec -> polkit -> helper grant runs
password-less under the group. fmt + clippy -D warnings + full suite green
(320 passed, 0 failed; the new test stays ignored in CI).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
Extends the in-kiosk harness to cover preboot and surfaces a robustness fix:

- waydroid_preboot_enables_multi_window: a new #[ignore] test that drives the
  real LinuxHost::preboot_waydroid and asserts a ready session with
  multi_windows=true (exercising preboot_container -> pkexec -> helper, real
  session boot + ready detection, prop set, idle-suspend). Passed ~94s; the
  set-and-restart branch passed under WAYDROID_TEST_FORCE_RESTART=1.
- test-waydroid.sh: runs preboot then launch/stop; the launch test uses an
  orchestrator-managed long-lived session (the session preboot starts is held
  by the short-lived test process and dies with it -- a test artifact, since
  shepherdd is long-lived in production). Force-restart is opt-in (heavy on
  constrained VMs).
- adapter: bump ANDROID_WINDOW_TIMEOUT 20s -> 45s. A cold first launch right
  after boot (software rendering) can exceed 20s where a warm one is ~2s;
  better a slow launch than killing an app about to show.

fmt + clippy -D warnings + full suite green (320 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
Implements child lock-in for Android sessions, verified on real Waydroid.

Empirically, HOME is only a soft escape (the app window closes -> shepherd
ends the session -> back to shepherd's own grid; Android home is never
composited). The real escape is the notification shade / quick settings ->
Settings, which `cmd statusbar send-disable-flag` blocks with no Device Owner
DPC app needed.

- helper: new `lock-down` subcommand -> `waydroid shell cmd statusbar
  send-disable-flag home recents statusbar-expansion notification-peek search`
  (fixed flags, no input).
- waydroid::lock_down via pkexec; [service.waydroid] lock_down (default true)
  threaded through configure_waydroid; spawn_android applies it per launch
  after WindowReady.
- test-waydroid.sh asserts it end-to-end: after a real launch, dumpsys
  statusbar shows mDisabled1=0x3210000.

Also documents (Part B) the operator runbook for GApps/Play certification, ARM
translation, and the wishlist apps, and the deferred gold-standard Device
Owner Lock Task for untrusted-content apps. fmt + clippy -D warnings + full
suite green (320 passed, 0 failed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
Add shepherd-dpc: Device Owner app for Android Lock Task Mode (#2)
Some checks failed
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 26s
CI / Rustfmt (pull_request) Successful in 14s
CI / Firewall E2E (pull_request) Failing after 1m29s
CI / Test (pull_request) Failing after 2m14s
CI / Build (pull_request) Failing after 2m18s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 28s
CI / Clippy (pull_request) Failing after 2m25s
CI / E2E (pull_request) Failing after 3m3s
1595c3a987
A minimal Device Policy Controller for Waydroid kiosk lock-in, built with a
plain SDK pipeline (aapt2/javac/d8/apksigner; no Gradle). Set as device owner,
it pins a kiosk app in Lock Task Mode (LaunchActivity allowlists the target and
launches it with setLockTaskEnabled, pinning even non-cooperating apps).

Verified on real Waydroid: device owner set on a fresh image, Lock Task LOCKED,
KEYCODE_HOME no longer escapes (foreground stays on the app) -- the
framework-level containment beyond the statusbar lock_down.

Blocking finding (documented): Lock Task Mode suppresses Waydroid's multi-window
toplevels, which shepherd's per-app fullscreen/tracking depends on -- while
pinned the app is not a waydroid.<pkg> Sway toplevel (unlocking restores it). So
the DPC is committed as a verified standalone capability but is NOT wired into
shepherd's launch path; doing so would break windowing. Adopting it later means
switching lock-task sessions to full-UI single-surface presentation + dumpsys
tracking. shepherd's default lock_down (statusbar) is unchanged.

APK/keystore/build artifacts are gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
albert changed title from u/albert/2/android-activity to Add support for Android activities 2026-06-28 18:33:10 +00:00
CI-runnable, no hardware:

- shepherd-waydroid-helper: refactor dispatch/validation into a pure
  parse_args -> Action (carrying the exact fixed argv it would exec), keeping
  main thin. Unit-tests the security boundary: force-stop rejects unsafe/
  single-segment/extra-arg packages, requires --package with a value;
  preboot/lock-down reject extra args; unknown/missing subcommands error; and
  the commands are fixed + shell-free (lock-down flag set asserted).
- shepherdd: extract should_preboot_waydroid(configured, has_android) and test
  the matrix (explicit preboot overrides; default follows presence of android
  entries).

cargo test --all-targets: 329 passed, 0 failed (+9); clippy -D warnings + fmt
clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqjouzyF5uAxbAkXPTKU2t
Merge remote-tracking branch 'origin/main' into u/albert/2/android-activity
Some checks failed
CI / ShellCheck (pull_request) Successful in 7s
CI / CI image (pull_request) Successful in 19s
CI / Rustfmt (pull_request) Successful in 10s
CI / Clippy (pull_request) Failing after 2m17s
CI / Test (pull_request) Successful in 2m54s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 36s
CI / Build (pull_request) Successful in 3m21s
CI / E2E (pull_request) Successful in 3m54s
CI / Firewall E2E (pull_request) Successful in 4m40s
01be8f992b
# Conflicts:
#	crates/shepherd-config/src/policy.rs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Aligns the DPC app's package with the companion app's
com.armeafamily.shepherd namespace (was com.shepherd.dpc). Moves the Java
sources into the new package directory and updates the manifest, README,
and design-doc references. The device-owner component is now
com.armeafamily.shepherd.dpc/.AdminReceiver.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
dpc: move the Device Owner app from android/shepherd-dpc to dpc-waydroid
Some checks failed
CI / Version harmony (pull_request) Successful in 5s
CI / ShellCheck (pull_request) Successful in 8s
CI / CI image (pull_request) Successful in 20s
CI / CI image (Android) (pull_request) Successful in 23s
CI / Rustfmt (pull_request) Successful in 52s
CI / Test (pull_request) Failing after 5m29s
CI / Clippy (pull_request) Failing after 5m53s
CI / Firewall E2E (pull_request) Successful in 5m3s
CI / Android portability (shepherd-media-core) (pull_request) Successful in 45s
CI / Android companion (unit tests) (pull_request) Successful in 41s
CI / Build (pull_request) Successful in 11m42s
CI / E2E (pull_request) Successful in 11m51s
da76765b59
Relocates the standalone Waydroid DPC (Device Policy Controller) app to a
top-level dpc-waydroid/ directory, dropping the now-single-child android/
parent. Pure path move; updates the design-doc references to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adopts main's CI/release pipeline (release.yml, images.yml, refreshed
ci.yml, Dockerfile.android) and the shepherd-media-android crate (#72).
The branch made no CI changes, so this brings its CI and releases in
line with main with no divergence to reconcile.

Only conflict: scripts/lib/install.sh — kept both this branch's
install_waydroid() and main's new install_system() helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
scripts: make the Waydroid install/admin path source + apt aware (#2)
Some checks failed
CI / Version harmony (pull_request) Successful in 40s
CI / Arch neutrality (pull_request) Successful in 42s
CI / ShellCheck (pull_request) Successful in 46s
CI / CI image (pull_request) Successful in 44s
CI / CI image (Android) (pull_request) Successful in 27s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 59s
CI / Clippy (pull_request) Failing after 5m45s
CI / Test (pull_request) Failing after 8m29s
CI / Android companion (unit tests) (pull_request) Successful in 1m14s
CI / Firewall E2E (pull_request) Successful in 4m17s
CI / Android media (cargo-ndk build) (pull_request) Successful in 3m42s
CI / Build (pull_request) Successful in 14m9s
CI / E2E (pull_request) Successful in 15m35s
CI / Package (.deb smoke build) (pull_request) Successful in 16m5s
bea090ea75
main's binary-releases work made the shell tooling dual-mode: install_system()
is the single DESTDIR-safe source of truth shared by `install all` and the .deb,
and heavy optional backends (Steam/Chrome) are provisioned on demand via
`shepherd-admin apps install`. The branch's Waydroid work predated that and was
source-only, so apt installs had no path to the Android activity kind.

Adopt the Steam-style opt-in model:

- install.sh: split install_waydroid() into install_waydroid_assets() (files
  only, DESTDIR-safe) + provision_waydroid_host() (group/membership/polkit,
  no-op under DESTDIR). install_system() now ships the helper + polkit assets, so
  the .deb and `install all` both carry them (inert until the group exists).
- admin.sh: new `apps install android [USER]` reuses provision_waydroid_host and
  prints the Waydroid-engine install steps. The Lock Task DPC stays out (not
  wired in by default).
- package.sh: waydroid polkit rule -> conffiles; Description + postinst mention
  the backend.
- Usage/docs (shepherd, shepherd-admin, README, INSTALL.md) list `android` and
  document both the source and packaged provisioning paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Prototyped Phase 1-2 (fresh GAPPS instance + DPC device owner) on the dev box.
Key result: dpm set-device-owner SUCCEEDS on a GAPPS image even with
device_provisioned=1 — the only gate is accounts=0 (set owner before Google
sign-in), overturning the earlier "likely blocked" assumption.

Gotchas captured: `waydroid init -f` does not wipe ~/.local/share/waydroid/data;
`waydroid app install` can silently no-op headless (use pm install via a pushed
/data file); Play Protect delays pm-install registration ~15s on GAPPS.

New history doc 2026-07-12 001; correction pointers added to 008/009; DPC README
provisioning section updated with the validated method + GAPPS notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Extend `shepherd-admin apps install android` from "provision the group + print
engine steps" to building the managed Android backend on top of an operator-
installed Waydroid engine (engine install stays guided). New scripts/lib/waydroid.sh:

- provision_waydroid_gapps: `waydroid init -s GAPPS` (idempotent; --clean opt-in
  to wipe user data, since `init -f` does not).
- install_libndk: amd64-only ARM translation. Overlay-based (no system.img
  surgery — with mount_overlays it drops the pinned upstream payload into
  /var/lib/waydroid/overlay/system and sets the native-bridge props in
  waydroid.cfg). Pinned URL + md5-verified; idempotent.
- install_dpc: pm-install the shipped apk (resolved via get_data_dir; poll past
  the ~15s Play Protect delay) and `dpm set-device-owner`, gated on a running
  session and accounts=0 (device owner must precede any Google sign-in).

Verified on the live box: idempotent GApps/libndk paths no-op, apk resolves, and
the accounts gate refuses set-device-owner on the signed-in device. The runtime
Lock Task integration (lock_mode enum, adapter rework) and CI apk shipping are
phases 2 and 3.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Groundwork for functional DPC Lock Task, ahead of the adapter windowing rework:

- shepherd-waydroid-helper: two new fixed, shell-free actions — `pin --package`
  (DPC LaunchActivity, pins an app in Lock Task) and `unlock` (DPC
  ControlReceiver). `pin` shares force-stop's package trust boundary; `shell --`
  guards the forwarded `--es` args. Wrapped in waydroid.rs as pin()/unlock().
- config: `[service.waydroid] lock_down: bool` -> `lock_mode` enum
  (off|statusbar|locktask). Back-compatible: lock_mode wins; else legacy
  lock_down (true/unset->statusbar, false->off). Unknown values rejected by
  validate_config. Default stays statusbar (no change for existing kiosks).
- adapter: WaydroidSettings carries a WaydroidLockMode; spawn_android branches on
  it. locktask currently falls back to the statusbar lock-down (never weaker)
  with a warning until the full pin + single-surface + dumpsys path lands.
- config.example.toml documents lock_mode; also fixes a pre-existing
  RawEntry test that missed the merged-in browser/confirm_on_close fields.

Tests: helper pin/unlock argv-fixity + trust-boundary; config lock_mode
resolution matrix. clippy -D warnings clean; config.example validates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Wire lock_mode="locktask" through the adapter as a real, second spawn_android
path — the DPC device owner now actually locks the kiosk. Validated end-to-end
against live Waydroid (new waydroid_locktask_launch_and_stop integration test).

Because Lock Task suppresses the per-app `waydroid.<pkg>` toplevel, a locktask
session is presented as the single full-UI `Waydroid` surface:
- spawn: `waydroid show-full-ui` -> wait for the `Waydroid` surface -> pin the
  app via the DPC (waydroid::pin). Pin twice with a short settle — the DPC's
  LaunchActivity can be briefly unresolvable right after the session comes up.
- stop: unlock (waydroid::unlock) + force_stop, then sway-`kill` the `Waydroid`
  window (a graceful close is ignored by the renderer; killing the show-full-ui
  child doesn't destroy the surface). The window-watch sees the surface go and
  emits Exited, reusing the existing exit path.
- preboot: force multi_windows OFF for locktask (full-UI needs it); every other
  mode keeps it as configured.
- sway.conf: `for_window [app_id="Waydroid"] fullscreen enable`, after the
  negative-lookahead disable (like wl_mirror). Windowed mode is unchanged.

The waydroid.rs pin/unlock wrappers + show_full_ui are now live (dead_code
allow removed). test-waydroid.sh gains a locktask phase (full-UI session,
multi_windows off); it skips if the DPC isn't device owner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
The packaged `apps install android` needs a prebuilt, correctly-signed DPC apk
(kiosks have no Android SDK). Ship it:

- dpc-waydroid/build.sh: honor DPC_KEYSTORE / DPC_KEYSTORE_PASS / DPC_KEY_PASS /
  DPC_KEY_ALIAS env (falling back to the local dev keystore), so CI can sign with
  the persistent release key.
- package.sh: stage a prebuilt dpc-waydroid/shepherd-dpc.apk into
  /usr/share/shepherd/shepherd-dpc.apk (where install_dpc resolves it via
  get_data_dir); a dev `package deb` without the apk just warns and omits it.
- release.yml: run the `deb` job in the android image (superset of base, has the
  SDK — Forgejo has no cross-job artifacts) and, when SHEPHERD_DPC_KEYSTORE_B64
  is set, build+sign the DPC apk before packaging so it's bundled, plus publish
  it as a standalone asset. The DPC key is UN-ROTATABLE (device-owner apps only
  update with the same key); documented in the secrets header.

If the DPC keystore secret is unset, the .deb builds fine without the Lock Task
backend — never shipping an apk signed with an ephemeral key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Update the DPC README (Lock Task is now integrated, not a dormant building
block), INSTALL.md (a lock_mode section covering statusbar/locktask/off), and add
the 2026-07-12 002 history doc covering the phased feature + the bench findings
(full-UI Waydroid surface, double-pin, sway-kill teardown, the DPC-resolution
harness artifact).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
ci(release): sign the DPC apk with the shared org key, not a separate one (#2)
Some checks failed
CI / Version harmony (pull_request) Successful in 33s
CI / Arch neutrality (pull_request) Failing after 34s
CI / ShellCheck (pull_request) Successful in 37s
CI / CI image (pull_request) Successful in 39s
CI / CI image (Android) (pull_request) Successful in 24s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 59s
CI / Package (.deb smoke build) (pull_request) Successful in 4m50s
CI / Clippy (pull_request) Successful in 6m12s
CI / Build (pull_request) Successful in 8m1s
CI / Android companion (unit tests) (pull_request) Successful in 2m10s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m6s
CI / E2E (pull_request) Successful in 9m17s
CI / Test (pull_request) Successful in 9m23s
CI / Firewall E2E (pull_request) Successful in 4m33s
64bf0e66d9
Drop the dedicated SHEPHERD_DPC_KEYSTORE_* secrets; the DPC apk now signs with
the shared org release keystore (SHEPHERD_KEYSTORE_*, same as companion + media)
— one fewer secret to manage, and the DPC has no signature-permission interop
need. Documented caveat: the DPC is a device owner (updatable only with the same
key), so the shared key is now effectively un-rotatable once any device is
provisioned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
The old section described the pre-managed behavior (manual `waydroid init` +
`multi_windows true`, `apps install android` only creating the group) — now
stale and contradictory. Replace it with the actual operator flow: requirements
(amd64, non-Nvidia GPU, binder kernel), (1) guided engine install, (2) re-run
`apps install android` to build the managed image (GApps init + libndk + DPC
set-device-owner, before any sign-in), (3) interactive Google sign-in + device
certification, (4) configure `type = "android"` activities + lock_mode. Folds
the lock_mode subsection in as step 4. These steps previously lived only in the
ai/history design docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
fix(android): satisfy the arch-neutrality check for libndk's amd64 gate (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 34s
CI / Arch neutrality (pull_request) Successful in 37s
CI / ShellCheck (pull_request) Successful in 40s
CI / CI image (pull_request) Successful in 41s
CI / CI image (Android) (pull_request) Successful in 34s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 59s
CI / Package (.deb smoke build) (pull_request) Successful in 4m51s
CI / Clippy (pull_request) Successful in 6m6s
CI / Android companion (unit tests) (pull_request) Successful in 1m57s
CI / Build (pull_request) Successful in 8m56s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m7s
CI / E2E (pull_request) Successful in 9m26s
CI / Test (pull_request) Successful in 9m29s
CI / Firewall E2E (pull_request) Successful in 4m22s
5f83f25453
check-arch-neutral.sh flags bare amd64/x86 literals in the packaged scripts
(they ship in the .deb) unless the line also names an arm arch. libndk is
genuinely amd64-only — arm64 runs ARM natively — so name arm64 on each such line
(more accurate too) and move the arch mention inline on the `if [[ "$arch" !=
"amd64" ]]` gate. No behavior change; the arch is still derived at runtime via
dpkg --print-architecture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Merge branch 'main' into u/albert/2/android-activity
All checks were successful
CI / Version harmony (pull_request) Successful in 32s
CI / Arch neutrality (pull_request) Successful in 36s
CI / ShellCheck (pull_request) Successful in 39s
CI / CI image (pull_request) Successful in 40s
CI / CI image (Android) (pull_request) Successful in 29s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 1m3s
CI / Package (.deb smoke build) (pull_request) Successful in 4m47s
CI / Clippy (pull_request) Successful in 6m1s
CI / Android companion (unit tests) (pull_request) Successful in 1m44s
CI / Build (pull_request) Successful in 8m32s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m13s
CI / E2E (pull_request) Successful in 9m14s
CI / Test (pull_request) Successful in 9m22s
CI / Firewall E2E (pull_request) Successful in 4m16s
ffcda44c25
Via `shepherd version set 0.3.0` (VERSION + Cargo.toml/lock + the excluded bpf
crate + shepherd-webui npm manifests); `version check` confirms all in sync.
Also bump the illustrative .deb/apk version in docs/INSTALL.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
ci(android): install zip for the DPC apk build (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 28s
CI / ShellCheck (pull_request) Successful in 37s
CI / Arch neutrality (pull_request) Successful in 36s
CI / CI image (pull_request) Successful in 39s
CI / CI image (Android) (pull_request) Successful in 7m24s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 53s
CI / Package (.deb smoke build) (pull_request) Successful in 10m24s
CI / Clippy (pull_request) Successful in 12m32s
CI / Android companion (unit tests) (pull_request) Successful in 1m26s
CI / Firewall E2E (pull_request) Successful in 3m52s
CI / Build (pull_request) Successful in 15m19s
CI / E2E (pull_request) Successful in 16m32s
CI / Android media (cargo-ndk build) (pull_request) Successful in 2m43s
CI / Test (pull_request) Successful in 17m8s
00f19b9a4e
The .deb release job builds the DPC via dpc-waydroid/build.sh, which uses `zip`
to add classes.dex to the apk (aapt2 has no equivalent). The android CI image
had only `unzip`, so the deb job failed with "zip: command not found". Add `zip`
to scripts/deps/android.pkgs — the android image tag hashes that file, so it
rebuilds with zip automatically, and local `deps install android` gets it too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
ci: build the DPC apk on every push (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 41s
CI / Arch neutrality (pull_request) Successful in 43s
CI / ShellCheck (pull_request) Successful in 47s
CI / CI image (pull_request) Successful in 48s
CI / CI image (Android) (pull_request) Successful in 26s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 53s
CI / Build (pull_request) Successful in 4m39s
CI / Test (pull_request) Successful in 4m58s
CI / E2E (pull_request) Successful in 6m6s
CI / Android DPC (build apk) (pull_request) Successful in 22s
CI / Android companion (unit tests) (pull_request) Successful in 1m54s
CI / Package (.deb smoke build) (pull_request) Successful in 6m13s
CI / Clippy (pull_request) Successful in 7m21s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m5s
CI / Firewall E2E (pull_request) Successful in 4m18s
2f7a80470f
Add an android-dpc job that runs dpc-waydroid/build.sh (the plain-SDK
aapt2/javac/d8/zipalign/apksigner pipeline) on the android image, so a build
break — a missing tool (cf. the recent `zip` gap), a broken source file — is
caught on every push/PR rather than only at release. Signs with the ephemeral
dev key build.sh auto-generates; the release .deb job re-signs with the
persistent org key.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
fix(android): DPC install survives Play Protect on an un-checked-in device (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 37s
CI / Arch neutrality (pull_request) Successful in 40s
CI / ShellCheck (pull_request) Successful in 44s
CI / CI image (pull_request) Successful in 12m34s
CI / CI image (Android) (pull_request) Successful in 7m20s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 1m1s
CI / Test (pull_request) Successful in 6m0s
CI / Build (pull_request) Successful in 6m2s
CI / E2E (pull_request) Successful in 6m30s
CI / Android DPC (build apk) (pull_request) Successful in 25s
CI / Android companion (unit tests) (pull_request) Successful in 1m17s
CI / Clippy (pull_request) Successful in 7m29s
CI / Package (.deb smoke build) (pull_request) Successful in 6m30s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m10s
CI / Firewall E2E (pull_request) Successful in 3m44s
0a8b3197d3
`apps install android` failed at the DPC step ("DPC package did not register")
on a fresh GApps device: Play Protect (GMS VerifyApps) gates pm install, and
before the device has checked in (no certification / no Google account) that
verification stalls or fails, so the package never registers. The tool also
swallowed pm install's output, hiding the reason.

install_dpc now disables the verifier (verifier_verify_adb_installs) around the
sideload of our own trusted apk and restores the prior value after, and surfaces
pm install's output in the failure message so future breakage is diagnosable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
The DPC install actually succeeds ("pm install said: Success") but GApps lags
registering it in `pm list packages` (GMS VerifyApps runs a ~15s pass even with
the verifier disabled). The previous 16s poll timed out before it appeared —
leaving the package installed but set-device-owner never run. Poll up to 60s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
install_dpc refused set-device-owner via a loose `grep type=com.google`, which
matches the always-present `AuthenticatorDescription {type=com.google}` even at
0 accounts — a false positive that would block provisioning on a fresh device.
Match only a real `Account {name=…, type=com.google}` line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
feat(android): cert-aware DPC provisioning + skip reinstalling same version (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 45s
CI / Arch neutrality (pull_request) Successful in 43s
CI / ShellCheck (pull_request) Successful in 48s
CI / CI image (pull_request) Successful in 46s
CI / CI image (Android) (pull_request) Successful in 25s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 50s
CI / Build (pull_request) Successful in 5m30s
CI / Test (pull_request) Successful in 5m32s
CI / E2E (pull_request) Successful in 6m3s
CI / Android DPC (build apk) (pull_request) Successful in 21s
CI / Android companion (unit tests) (pull_request) Successful in 1m27s
CI / Package (.deb smoke build) (pull_request) Successful in 6m13s
CI / Clippy (pull_request) Successful in 7m16s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m10s
CI / Firewall E2E (pull_request) Successful in 3m30s
41cfdac044
- install_dpc: skip the reinstall when the DPC of the same version is already
  installed (avoids the GApps re-registration lag + reinstalling a device-owner
  app for nothing); a different version still (re)installs. Also short-circuit
  when the DPC is already device owner. DPC versionName now tracks shepherd's
  VERSION (dpc-waydroid/build.sh derives versionCode/name from it) so the compare
  is meaningful.
- Certification: `apps install android` now prints the GSF Android ID + how to
  register it ONLY when the device is uncertified (GServices uncertified_status),
  and confirms "Device is Play-certified." otherwise — no more nagging on a
  certified device.
- Fix waydroid_shell to strip ALL of waydroid's [HH:MM:SS] status chatter,
  including the lxc-freeze/unfreeze/FROZEN lines emitted when the idle-suspended
  container is thawed (they were polluting the printed GSF ID).

Verified on the box: version derivation (0.3.0 -> code 300), the uncertified
notice prints a clean GSF ID, and the same-version skip / already-owner paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
fix(dpc): compare installed DPC against the apk's real version, not repo VERSION (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 33s
CI / Arch neutrality (pull_request) Successful in 37s
CI / ShellCheck (pull_request) Successful in 41s
CI / CI image (pull_request) Successful in 42s
CI / CI image (Android) (pull_request) Successful in 26s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 52s
CI / Test (pull_request) Successful in 5m6s
CI / Build (pull_request) Successful in 5m9s
CI / E2E (pull_request) Successful in 5m51s
CI / Android DPC (build apk) (pull_request) Successful in 21s
CI / Package (.deb smoke build) (pull_request) Successful in 5m27s
CI / Android companion (unit tests) (pull_request) Successful in 1m19s
CI / Clippy (pull_request) Successful in 6m35s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m14s
CI / Firewall E2E (pull_request) Successful in 3m49s
92b12ec28d
install_dpc compared the installed DPC's versionName against the repo-root
VERSION, so on a device with an older DPC (e.g. 1.0) and an un-rebuilt apk it
would perpetually "update 1.0 -> 0.3.0", re-triggering the ~15s GApps
registration lag and often timing out — even though the device was already
fully provisioned (device owner, accounts=0).

Compare against the apk's actual version instead, sourced from a `.version`
sidecar that build.sh now writes next to the apk (a kiosk has no Android SDK /
aapt to read the manifest). Skip the reinstall when the installed version
matches the apk, OR when the apk's version can't be determined (an old build
with no sidecar) — never clobber an already-installed device-owner app for an
unknown apk. A genuinely newer apk still installs.

- dpc-waydroid/build.sh: derive versionName/versionCode from VERSION, write
  the `.version` sidecar, gitignore it.
- scripts/lib/package.sh: stage the sidecar alongside the apk in the .deb.
- scripts/lib/waydroid.sh: sidecar-based version compare; fix an orphaned
  doc comment and a stale want_ver reference.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
feat(android): hide Android activities until the Waydroid session is up (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 34s
CI / Arch neutrality (pull_request) Successful in 37s
CI / ShellCheck (pull_request) Successful in 41s
CI / CI image (pull_request) Successful in 41s
CI / CI image (Android) (pull_request) Successful in 28s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 52s
CI / Build (pull_request) Successful in 5m24s
CI / Test (pull_request) Successful in 5m27s
CI / E2E (pull_request) Successful in 6m9s
CI / Android DPC (build apk) (pull_request) Successful in 25s
CI / Android companion (unit tests) (pull_request) Successful in 1m21s
CI / Package (.deb smoke build) (pull_request) Successful in 6m20s
CI / Clippy (pull_request) Successful in 7m21s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m7s
CI / Firewall E2E (pull_request) Successful in 3m50s
2bc8f87a0d
Android tiles were always shown and clickable even when Waydroid wasn't
running, so a launch would hard-error (spawn_android requires a RUNNING
session). Mirror the Steam readiness gate (issue #76): gate Android activities
on the session state so a tile appears only when a launch could actually
succeed.

- LinuxHost::spawn_waydroid_readiness_watcher: emits
  KindReadinessChanged { Android, false } up front, then polls
  waydroid::session_running() and emits on every transition. Unlike Steam's
  one-shot load signal the session comes and goes (pre-boot, idle-suspend,
  manual stop, crashes), so this is a *live* gate — it re-hides Android when
  the session drops and re-shows it when it returns.
- shepherdd: when any Android entry exists, seed the engine not-ready and start
  the watcher (independent of pre-boot, so a manually-started session un-gates
  too), so the first served snapshot already gates Android.

Everything downstream (engine gate, KindReadinessChanged handler, NotReady
reason, launcher grid filter) is already kind-generic, so no changes there.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
The `lock_mode = "locktask"` full-UI surface was forced `fullscreen enable`, so
it covered the whole output *under* the HUD layer-shell surface — the HUD then
drew on top and obscured the Android app. Drop the fullscreen rule so the
surface falls through to the `fullscreen disable` above and maximizes to fill
the space *around* the HUD's exclusive zone, like any normal activity window.
Lock Task containment is enforced by the DPC at the Android framework level,
independent of how the surface is presented, so this doesn't weaken lock-in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
fix(android): gate the launcher on Android boot-completion, not session-up (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 38s
CI / Arch neutrality (pull_request) Successful in 41s
CI / ShellCheck (pull_request) Successful in 45s
CI / CI image (pull_request) Successful in 45s
CI / CI image (Android) (pull_request) Successful in 24s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 54s
CI / Build (pull_request) Successful in 5m34s
CI / Test (pull_request) Successful in 5m33s
CI / E2E (pull_request) Successful in 6m1s
CI / Android DPC (build apk) (pull_request) Successful in 21s
CI / Android companion (unit tests) (pull_request) Successful in 1m28s
CI / Package (.deb smoke build) (pull_request) Successful in 6m8s
CI / Clippy (pull_request) Successful in 7m11s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m10s
CI / Firewall E2E (pull_request) Successful in 3m21s
b928d414fb
The readiness gate keyed on `waydroid status` Session:RUNNING, but that flips
true within a second of `session start` while Android needs another ~20-60s to
finish booting (measured: RUNNING at 0s, sys.boot_completed at 22s). So Android
tiles un-hid mid-boot and a launch in that window dropped the child on the
LineageOS boot animation / home screen.

Gate on Android boot-completion instead:

- shepherd-waydroid-helper: new `boot-completed` action — exits 0 iff
  `getprop sys.boot_completed` prints 1. It's a query (inspects output; getprop
  always exits 0), so main() handles it without exec. No polkit change (same
  single gated action id).
- waydroid::boot_completed(): pkexec wrapper; false if the session is down or
  the helper is absent.
- spawn_waydroid_readiness_watcher: ready = session running AND boot_completed.
  The privileged boot-completed check runs only while still waiting to become
  ready; once ready a cheap `waydroid status` suffices to notice the session
  dropping (Android can't un-boot without the session going away first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
feat(android): fill the host window in multi-window mode via am task resize (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 38s
CI / ShellCheck (pull_request) Successful in 43s
CI / Arch neutrality (pull_request) Successful in 43s
CI / CI image (pull_request) Successful in 46s
CI / CI image (Android) (pull_request) Successful in 25s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 53s
CI / Build (pull_request) Successful in 5m32s
CI / Test (pull_request) Successful in 5m34s
CI / E2E (pull_request) Successful in 6m2s
CI / Android DPC (build apk) (pull_request) Successful in 22s
CI / Android companion (unit tests) (pull_request) Successful in 1m21s
CI / Package (.deb smoke build) (pull_request) Successful in 6m7s
CI / Clippy (pull_request) Successful in 7m10s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m11s
CI / Firewall E2E (pull_request) Successful in 3m26s
2ba7a9070c
In statusbar/off (multi-window) mode Waydroid opens each app in a small default
freeform window and never grows the Android task to follow the host window —
Sway maximizes the toplevel, but Waydroid just scales the partial task into it,
so the app renders as a small, offset window with dead space. (In this freeform
mode Android also ignores the app's fixed orientation, so a portrait-locked app
like Khan Academy fills in landscape with the large-screen/tablet layout — but
only once the task actually fills the display.)

Fix: after a multi-window app's window maps, resize its freeform task to fill
the display. Verified live: a portrait-locked app goes from a ~1033x674 offset
window to the full 1920x999 landscape display.

- shepherd-waydroid-helper: new `maximize --package <pkg>` action. Multi-step
  (reads dumpsys, confirms <pkg> is the resumed app so it never resizes an
  unrelated/closed task, parses the top task id + display size, then
  `am task resize <id> 0 0 W H`), so like `boot-completed` it doesn't exec.
  Parsing split into pure `parse_top_task_id` / `parse_wm_size` with unit tests.
- waydroid::maximize(): pkexec wrapper, best-effort.
- spawn_android: call it in the non-locktask branch once the window is up.
  Locktask (single full-UI surface) is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
In `lock_mode = "statusbar"` a Waydroid app is a multi-window (freeform)
toplevel with an Android caption bar (drag handle + minimize/maximize/close) —
an escape hatch on a kiosk. Fullscreen those windows so they render under the
HUD (an overlay-layer surface), which covers the caption; and since that hides
Android's own back too, add a back button to the HUD.

- sway.conf: `for_window [app_id="^waydroid\..*"] fullscreen enable` (per-app
  multi-window toplevels only; the locktask `Waydroid` full-UI surface stays
  non-fullscreen).
- HUD (shepherd-hud): a back button before the activity title, shown only for an
  open Android activity, wired to a new `back` RPC. Needs to know the session is
  Android, so `kind_tag: EntryKindTag` is plumbed onto `SessionInfo` + the
  `SessionStarted` event and into the HUD's `SessionState`.
- RPC chain: `client.back()` → `ManagementService::back` → `HostAdapter::send_back`
  → `LinuxHostAdapter` (Android payload) → `waydroid::back()` → helper `back`.
- helper: `back` action runs `waydroid shell input keyevent 4`.

Root-cause note (validated on-device): Waydroid/Android 13 dispatches BACK to
the *input-focused* window, and that focus is only set once the app has been
interacted with (a fresh `am start` leaves FocusedWindows empty) — so injected
BACK is a no-op until then, while HOME (pre-dispatch) and taps (coordinate) work
regardless. keyevent BACK works once the app is focused, which normal child
interaction provides; an accessibility `GLOBAL_ACTION_BACK` has the same
requirement (it injects a key too), so no DPC accessibility service is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Reproduced by closing/reopening an Android activity many times in a session:
Waydroid's per-session `waydroidplatform` service eventually wedges, and
`waydroid app launch` then loops "Failed to get service waydroidplatform, trying
again..." *forever*. `launch_app` awaited it with no timeout, so `spawn_android`
blocked indefinitely — the launcher sat on a "Loading" spinner with no way to
cancel, and the whole session was stuck (the wedge isn't visible in
`waydroid status`, which still reports RUNNING/booted).

Spawn the launch command instead of `.status()`-awaiting it, bound it with a
20s timeout (normal launch returns in ~1s; a healthy one is unaffected), and
kill it + return SpawnFailed on timeout. spawn_android then fails cleanly, the
management launch path tears the session down and re-broadcasts state, and the
launcher returns to the grid.

Validated headless against an actually-wedged session: launch now fails in 20.1s
with SpawnFailed, the launcher goes back to the grid, and the IPC stays
responsive (current_session returns null in 0.02s). Only `waydroid app launch`
hangs on this wedge — `waydroid shell` calls (force-stop/maximize/back) kept
working — so this one bound covers it.

Follow-up (not fixed here): the session stays wedged, so subsequent launches
keep failing until Waydroid is restarted; auto-recovering the session on repeated
launch timeouts would be a further improvement.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Builds on the launch-timeout fix: when `launch_app` hits the timeout — i.e. the
host↔`waydroidplatform` bridge is wedged — restart the Android session so the
next launch works, instead of leaving every launch to fail until manual
intervention.

`launch_app` now returns `LaunchError::Wedged` (vs `Failed`) on timeout.
spawn_android, on `Wedged`, calls `recover_wedged_waydroid`, which re-gates
Android immediately (KindReadinessChanged{Android,false} — the launcher hides the
tile while it's down) and, in the background (guarded so overlapping wedges
restart only once), stops the session, ensures the container is up, and restarts
it. The readiness watcher un-gates once it boots again.

Root cause of the wedge (investigated via the headless harness): rapid
close/reopen *racing* — reopening before the previous launch settles — kills a
heavy React-Native app (Khan) mid-start (ActivityManager "Killing ... adj 0",
"app died, no saved state"), and the churn wedges the host↔session gbinder bridge
to `waydroidplatform` (still registered, but the host's `waydroid app launch`
loops "Failed to get service" forever). Not OOM ("Not killing cached processes").
Graceful close/reopen that waits for settle is fine — 30/30 clean at ~0.9s.

Validated: the 20s launch timeout turns a real wedge into a 20.1s SpawnFailed
(launcher returns to the grid, IPC stays responsive); a session restart clears a
wedge (launch works after). The live end-to-end recovery trigger wasn't caught —
the launch-hang wedge is stochastic and didn't reproduce on demand this run
(racing mostly produced the benign "no window in 45s" failure, which correctly
does not trigger recovery). Worth confirming on real hardware.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Root of the wedge: reopening an Android app before the previous instance finishes
tearing down races `waydroid app launch` against the kill, and the churn wedges
the host↔session platform bridge. Prevent it: before launching, wait for the
previous instance to actually be gone.

`spawn_android` now calls `waydroid::ensure_app_stopped(package)` before launch —
if the package still has a live Android process it force-stops it and polls until
gone (bounded 6s, then launches anyway rather than block) plus a 400ms settle.
The check goes through a new privileged helper query `is-running --package`
(`pidof`), mirroring `boot-completed`. It's cheap on the common path: one
`is-running` check that returns immediately when nothing's running (~1s launches
unchanged).

Validated headless with the same inv3 racing pattern that wedged the bridge at
cycle 5 before: 24 cycles across two runs with the guard, no launch-hang wedge,
launches stayed ~1s, session healthy throughout. (Force-closing an app *before*
its window ever maps still yields the benign "no window in 45s" SpawnFailed —
that's closing mid-launch, not a bridge wedge, and it fails cleanly to the grid.)
Together with the launch timeout + auto-recovery, this makes the wedge rare and
self-healing when it does slip through.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
On a fractional compositor scale (e.g. `output * scale 1.5`) Waydroid sizes its
Wayland buffer to the output's *logical* size (physical / scale) unless
`persist.waydroid.{width,height}` are pinned — which they aren't by default. So
it renders 1/scale too small. It's read at session start, so it only bites when
the session (re)starts while scaled: the initial preboot often races sway's scale
application and starts at scale 1 (correct), then a mid-session restart
(idle-suspend recovery, or the new wedge auto-recovery) restarts at 1.5 and the
window shrinks to 1.5x too small — "correct at first, shrinks during usage".

preboot now pins `persist.waydroid.{width,height}` to the primary output's
*physical* mode (via `get_displays().current_mode`), alongside the existing
multi-window prop, with a single restart when anything changed. Persist props
survive restarts, so the wedge-recovery and idle-suspend restarts inherit them
and stay full-size.

Validated headless at `output scale 1.5`: without the pin a session restart
dropped the app buffer from 1280x720 to 853x426 (1.5x too small); with the pin
the resolution auto-set at preboot (width=1280 height=720) and the buffer stayed
1280x720 (filling the panel) across a restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
feat(android): re-pin Waydroid resolution on display change (#2, #87)
Some checks failed
CI / Version harmony (pull_request) Successful in 38s
CI / Arch neutrality (pull_request) Successful in 37s
CI / ShellCheck (pull_request) Successful in 40s
CI / CI image (pull_request) Successful in 41s
CI / CI image (Android) (pull_request) Successful in 7m15s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 54s
CI / Test (pull_request) Failing after 5m30s
CI / Clippy (pull_request) Failing after 7m5s
CI / Android companion (unit tests) (pull_request) Successful in 1m20s
CI / Android DPC (build apk) (pull_request) Successful in 18s
CI / Firewall E2E (pull_request) Successful in 3m55s
CI / Android media (cargo-ndk build) (pull_request) Successful in 2m20s
CI / Build (pull_request) Successful in 14m46s
CI / E2E (pull_request) Successful in 15m40s
CI / Package (.deb smoke build) (pull_request) Successful in 14m41s
6943183281
Follow-up to pinning the resolution at preboot: the pin goes stale if the primary
output's physical mode changes at runtime (docking a different-resolution monitor,
issue #87), so a later session start would render the wrong size again.

The docking hotplug watcher (`display_watch`, already subscribed to sway `output`
events) now also nudges the host: `LinuxHost::repin_waydroid_resolution` re-reads
the primary output's physical mode, updates `persist.waydroid.{width,height}` if
it changed, and — when a session is up — restarts it (re-gating Android meanwhile)
so the new resolution applies immediately. No-op when Waydroid isn't configured,
the mode is unchanged, or a restart is already in flight (it self-debounces on the
prop comparison, so the burst of events one hotplug emits collapses to one
restart).

Validated headless: preboot pinned 1280x720; changing the output mode to 1920x1080
fired the watcher → props re-pinned to 1920x1080 and the session restarted at the
new resolution (wm size 1920x1080), session healthy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Video playback in Khan was quiet even at full host volume: Android's per-stream
volume sits *before* the host PulseAudio sink shepherd controls, and STREAM_MUSIC
defaults to 5/15 (~33%), so media was attenuated to a third before shepherd's
volume ever applied — the slider only spanned the bottom third of the real range.

New `max-volume` helper action pins STREAM_MUSIC to max, handing the full dynamic
range to shepherd's own volume. shepherdd calls it after each launch (all lock
modes — media plays in locktask too; the setting can drift within a session).

Notes from getting the command right on this LineageOS 13 image:
- The `media` CLI is absent and `cmd audio` can't set volume; the working setter
  is `cmd media_session volume`.
- `waydroid shell` eats a forwarded `--stream`/`--get` as its own flag, so it
  needs the `shell --` separator (as `pin`/`unlock` already do).
- `--set INDEX` rejects an out-of-range index rather than clamping, and the max is
  ROM-specific, so `max-volume` is a two-step query action like `maximize`: read
  the max from `--get`'s `[0..N]`, then `--set` it.

Validated live (helper 4->15; shepherd's exact pkexec path 3->15) plus a
parse_volume_max unit test. Also refreshes the now-stale "both subcommands" note
in the polkit policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
feat(android): hide the redundant Waydroid nav bar (#2)
Some checks failed
CI / Version harmony (pull_request) Successful in 29s
CI / Arch neutrality (pull_request) Successful in 33s
CI / ShellCheck (pull_request) Successful in 36s
CI / CI image (pull_request) Successful in 37s
CI / CI image (Android) (pull_request) Successful in 25s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 54s
CI / Build (pull_request) Failing after 6m23s
CI / Test (pull_request) Failing after 6m21s
CI / Android companion (unit tests) (pull_request) Successful in 1m40s
CI / Android DPC (build apk) (pull_request) Successful in 24s
CI / Clippy (pull_request) Failing after 9m35s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m14s
CI / Firewall E2E (pull_request) Successful in 4m32s
CI / Package (.deb smoke build) (pull_request) Successful in 16m7s
CI / E2E (pull_request) Successful in 18m34s
bf30f10dca
In the kiosk the Android software nav bar only ever shows Back (or nothing): the
HUD surfaces Back and lock-down disables home/recents/search. Hide it via
qemu.hw.mainkeys=1, which makes Android draw no software nav bar at all
(policy_control immersive was removed in Android 11+ and is a no-op on 13).

The prop is applied at container start from waydroid_base.prop, which is only
regenerated from waydroid.cfg's [properties] by `waydroid init`/`upgrade`. So the
new `configure_waydroid_navbar` records it in the cfg (durable across upgrades)
and runs `waydroid upgrade -o` (offline; no image download) once to bake it in.
Wired into `apps install android` after install_libndk; idempotent on the baked
base.prop so the second (DPC-provisioning) pass is a no-op and won't restart the
container out from under the running session it needs.

The history doc records why mainkeys (not immersive) and the cfg+upgrade pipeline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
feat(android): render Waydroid at native scale 1 + density zoom on fractional-scale panels (#2)
Some checks failed
CI / Version harmony (pull_request) Successful in 37s
CI / Arch neutrality (pull_request) Successful in 35s
CI / ShellCheck (pull_request) Successful in 38s
CI / CI image (pull_request) Successful in 39s
CI / CI image (Android) (pull_request) Successful in 25s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 56s
CI / E2E (pull_request) Failing after 5m4s
CI / Test (pull_request) Failing after 5m47s
CI / Android companion (unit tests) (pull_request) Successful in 1m48s
CI / Clippy (pull_request) Failing after 7m42s
CI / Android DPC (build apk) (pull_request) Successful in 19s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m6s
CI / Firewall E2E (pull_request) Successful in 4m26s
CI / Build (pull_request) Successful in 13m53s
CI / Package (.deb smoke build) (pull_request) Successful in 18m46s
e938a1f673
On `output * scale 1.5`, Waydroid rendered wrong — either content magnified and
clipped ("too large") or the window letterboxed ("too small"). Both are the same
defect: Waydroid can't handle a fractional wl_output scale. It assumes the output's
logical size equals its mode and allocates its Wayland buffer at session-boot scale,
so it never fills the panel correctly; persist.waydroid.width/height is ignored at
fractional scale and a mid-session scale change is ignored too (the buffer is
boot-locked). This overturns the earlier pin-to-physical-mode approach
(bcbb18a/6943183), which is a no-op at fractional scale.

Waydroid's integer-scale path is flawless, so run the session at native scale 1 and
express the panel's zoom as Android density — reusing the XWayland HiDPI machinery
the user already relies on for Steam:

- service.rs: Android entries set needs_hidpi, so hidpi.apply() drops output to
  scale 1 (+ HudScaleChanged so the HUD counter-scales, + dm.reassert for the mirror)
  at launch and restore() on exit. HidpiController::apply() now returns the captured
  scale, threaded into SpawnOptions.android_ui_scale.
- spawn_android: if scale > 1, ensure the warm session actually booted at scale 1
  (restart once if the waydroid_scale1_booted flag is unset — output is scale 1 by
  then, so it reboots with a correct buffer; flag reset in preboot/recovery/repin),
  then set density = base x scale via the new helper action `scale-density <permille>`.

Docking composes: DisplayManager only sets output mode (not scale), so scale-1
doesn't fight it; mirroring is wl-mirror screencopy of the primary (no-op); a
mode-changing dock/external-only transition triggers the existing repin restart,
which resets the flag so the next launch re-verifies.

Validated headless end-to-end: session booted at scale 1.5 (wm 1920x1080), launch ->
output scale 1.0, Waydroid restarted (wm 1280x720), Override density 270, window
fills 1280x720, crisp, HUD correct size. Cost: the first scaled Android launch per
session boot restarts Waydroid; subsequent launches skip it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
fix(android): stop the docking repin from racing the native-scale restart (#2)
Some checks failed
CI / Version harmony (pull_request) Successful in 31s
CI / Arch neutrality (pull_request) Successful in 33s
CI / ShellCheck (pull_request) Successful in 36s
CI / CI image (pull_request) Successful in 36s
CI / CI image (Android) (pull_request) Successful in 24s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 46s
CI / Clippy (pull_request) Failing after 6m41s
CI / Package (.deb smoke build) (pull_request) Successful in 6m5s
CI / Build (pull_request) Successful in 7m44s
CI / Test (pull_request) Failing after 7m55s
CI / Android DPC (build apk) (pull_request) Successful in 23s
CI / Android companion (unit tests) (pull_request) Successful in 1m35s
CI / E2E (pull_request) Successful in 9m1s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m11s
CI / Firewall E2E (pull_request) Successful in 3m55s
c393d9168b
On a fractional-scale panel, launching an Android activity wedged Waydroid (session
running, container stopped) and the launcher spun forever. The HiDPI workaround
drops the output scale to 1, which fires the docking output-watch → repin. The old
repin detected a resolution change by reading `persist.waydroid.{width,height}` from
the *live* session — but the native-scale restart had just stopped it, so the read
returned empty and was misread as a mode change, kicking off a second, racing
restart that wedged the session and re-fired on every later scale event.

repin now compares the current physical mode against a mode last-pinned in host
state (waydroid_pinned_mode), never the session. An output *scale* change leaves the
physical mode unchanged, so it's a clean no-op and never touches a possibly-stopped
session. The native-scale restart in spawn_android also takes the recovery guard so
it can't race a genuine mode-change repin or a wedge recovery.

Validated on the dev box reproducing the leibniz condition (session booted at scale
1.5): launch now logs zero "Display changed; restarting", the session stays healthy
(container RUNNING), and Khan renders at wm 1280x720 + density 270. Fixes the
regression from e938a1f as seen on leibniz.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
On a fractional-scale panel, the first Android launch rendered correctly but a
close+reopen squashed the app into the top-left corner: Waydroid's boot-locked
buffer reverts to the logical resolution on the warm session, so a reopen that
skipped the native-scale restart came up at the wrong size.

Drop the `waydroid_scale1_booted` one-shot flag so every scaled launch does the
scale-1 restart (reopen now behaves like the working first launch). Costs a Waydroid
session restart per open — correctness over speed until the underlying warm-session
resolution drift is understood. The recovery-guard interlock is kept.

Note: validated only by reasoning about the first-launch-works / reopen-squashes
delta on leibniz — the headless dev box can't faithfully reproduce a real 1080p
panel, so this is deployed to leibniz for on-device confirmation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
docs(android): record the Waydroid fractional-scale diagnosis + upstream fix brief (#2)
Some checks failed
CI / Version harmony (pull_request) Successful in 38s
CI / Arch neutrality (pull_request) Successful in 38s
CI / ShellCheck (pull_request) Successful in 41s
CI / CI image (pull_request) Successful in 42s
CI / CI image (Android) (pull_request) Successful in 25s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 57s
CI / Package (.deb smoke build) (pull_request) Successful in 6m4s
CI / Clippy (pull_request) Failing after 7m14s
CI / Build (pull_request) Successful in 8m52s
CI / Test (pull_request) Failing after 8m56s
CI / Android companion (unit tests) (pull_request) Successful in 1m42s
CI / Android DPC (build apk) (pull_request) Successful in 27s
CI / E2E (pull_request) Successful in 9m37s
CI / Android media (cargo-ndk build) (pull_request) Successful in 1m6s
CI / Firewall E2E (pull_request) Successful in 4m1s
e15c0b34f4
Diagnosis-only session: on fractional-scale outputs, Waydroid's hwcomposer latches
the wp_fractional_scale_v1 preferred scale at session boot and never updates it. Any
warm exposure to a different scale (live, or queued and replayed across an lxc
freeze/thaw) permanently poisons presentation — every later surface displays at
exactly half size, top-left — while Android-side state stays correct. Only a session
reboot recovers, which is why the current per-launch reboot (2920dfe) is the shipped
stopgap and why it can't be optimized away compositor-side.

The history note records the debugging (including why the headless harness diverged
from real hardware: the post-start mode-set races shepherdd's preboot); the
docs/ai/ brief is self-contained for an agent to pursue the upstream hwcomposer fix,
with a shepherd-free reproduction, evidence, suspect code areas, and validation
criteria.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
Re-lands the c393d91 flag design (scale-1 restart only when the session hasn't
booted at scale 1 yet; flag reset by preboot/recovery/repin), reverting 2920dfe's
restart-on-every-launch stopgap.

The stopgap existed because Waydroid's hwcomposer latched the fractional output
scale at session boot: any warm exposure to scale 1.5 (live, or replayed across a
freeze) permanently halved presentation, so only a per-open reboot was safe (see
docs/ai/waydroid-fractional-scale-upstream.md). The Waydroid image is now patched
(overlay vendor/lib64/hw/hwcomposer.waydroid.so) and validated against that brief's
criteria on the faithful headless rig (config-time `output * scale 1.5`, 1920x1080):

- warm live-flip relaunch: correct (was half-squash)
- freeze/thaw flip relaunch: correct
- end-to-end launcher flow: one restart on first open, then 4 consecutive
  close/reopen cycles at 1-2 s each, pixel-perfect (was ~30-60 s per open)
- steady-state boot at 1.5: improved but still double-scales (wm 2880x1620) —
  recorded in the brief; not hit by the kiosk flow, which launches at scale 1

DEPLOY NOTE: this fast path REQUIRES the patched hwcomposer. On an unpatched image
a reopen renders at half size — install the overlay patch before shipping this
binary to a kiosk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
docs(android): record second hwcomposer patch validation — tracking fixed, boot double-scale remains (#2)
Some checks failed
CI / Version harmony (pull_request) Successful in 39s
CI / Arch neutrality (pull_request) Successful in 39s
CI / ShellCheck (pull_request) Successful in 41s
CI / CI image (pull_request) Successful in 42s
CI / CI image (Android) (pull_request) Successful in 7m25s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 57s
CI / Clippy (pull_request) Failing after 4m3s
CI / Test (pull_request) Failing after 6m24s
CI / Android companion (unit tests) (pull_request) Successful in 1m22s
CI / Android DPC (build apk) (pull_request) Successful in 16s
CI / Firewall E2E (pull_request) Successful in 4m4s
CI / Build (pull_request) Successful in 9m57s
CI / Android media (cargo-ndk build) (pull_request) Successful in 2m48s
CI / E2E (pull_request) Successful in 10m52s
CI / Package (.deb smoke build) (pull_request) Successful in 11m13s
baff668d1c
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpf
# Conflicts:
#	.github/workflows/release.yml
#	Cargo.lock
#	Cargo.toml
#	VERSION
#	crates/shepherd-config/src/policy.rs
#	crates/shepherd-config/src/validation.rs
#	crates/shepherd-firewall-bpf/Cargo.lock
#	crates/shepherd-firewall-bpf/Cargo.toml
#	crates/shepherd-management/src/service.rs
#	docs/INSTALL.md
#	scripts/shepherd
#	shepherd-webui/package-lock.json
#	shepherd-webui/package.json
Notes the conflict resolutions, the two semantic conflicts that merged
cleanly but broke the build, and the wire-codegen regeneration that the
drift test caught. Also documents the JDK 21 requirement in CONTRIBUTING
— a newer default JDK fails the Gradle build with an unhelpful bare
version-string exception.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
types.ts is hand-written, so rpc-codegen never filled the Android entry
kind in and the drift test can't catch it. Ordered after "flatpak" to
match the Rust and generated-Kotlin orderings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
Nothing tore the Waydroid session down: ending an Android activity only
closes the window and force-stops the app, and shepherdd's graceful
shutdown stopped the active session and preloaded Steam but left the
container running. A booted Android therefore outlived shepherdd and
survived logout, holding its memory.

Mirror stop_steam_preload: tear down only what shepherd started, tracked
with waydroid_prebooted so `preboot = false` plus a hand-started session
is left alone. Bounded so a wedged session degrades to a leaked container
rather than a shepherdd that never exits. The root waydroid-container
service is left alone — it is system-managed, and preboot only ensures
it is up.

Verified against a live session: waydroid_stop_on_shutdown covers both
the no-op and the teardown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
`launch` held SessionStarted until after `host.spawn` returned. For most
kinds that is imperceptible, but an Android cold start blocks the spawn
for tens of seconds — the first open on a fractional-scale panel restarts
the Waydroid session, then waits for the app's toplevel to map. Until the
event landed the HUD had no session to draw, so the child sat on a
"Loading" spinner with no stop button and no way out.

The engine already had the session: `start_session` returns the very
SessionStarted event we needed and the caller dropped it. Broadcast it
there instead. The launcher benefits too — it moves from the generic
spinner to the "Loading: <label>" session view, which already exists for
exactly this purpose.

Announcing early means every early return owes listeners a retraction, so
add abort_announced_session (SessionEnded + StateChanged) and use it on
both the spawn-failure and entry-not-found paths. Without it the HUD
would keep showing a session that never began.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
Measured on the faithful rig (config-time `output * scale 1.5`, 1920x1080,
patched hwcomposer): first Android open 81.2s, reopen 5.9s. The gap is the
scale-1 session restart in spawn_android — preboot booted the session at the
grid's fractional scale, so the first launch had to stop it and boot again on
the native pixel grid before it could present correctly.

Boot it right the first time instead: preboot drops every output to scale 1
for the duration of the session boot, then restores. Waydroid latches its
display geometry from the output scale it sees at boot and cannot be corrected
warm, so this is the one moment where the scale matters. Net effect is one boot
at startup instead of two — strictly less work than before.

  first open  81.2s -> 6.9s
  reopen       5.9s -> 2.2s
  wm size     1920x999 (logical x scale) -> 1920x1080

Screenshot-verified full-screen and crisp; no squash, clipping, or half-size.
The restart in spawn_android stays as the fallback for the paths that still
invalidate the flag (wedge recovery, docking repin) and for an adopted session
whose boot scale we never observed.

Costs a startup window (~64s cold here) where the launcher/HUD render at scale
1 and so look physically smaller, while Android boots in the background. That
trades a mid-use stall for a boot-time cosmetic.

Also fixes a readiness desync found while measuring: wedge recovery and the
docking repin emit KindReadinessChanged{Android,false} directly, but the
watcher kept its own `last` cache, so it would short-circuit on a stale `true`
and never re-emit. Android stayed hidden from the launcher until shepherdd
restarted. The cache is now shared and those paths record what they published;
verified re-un-gating after a repin on the rig.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
Writes up the two defects behind the slow first open (the withheld
SessionStarted and the scale-1 restart), the before/after numbers from the
faithful rig, the readiness desync found while measuring, and the environment
gotchas that cost real time — notably the missing polkit rules file, without
which the Android readiness gate can never open.

Updates the upstream brief: the per-open cost is gone, but the constraint that
motivates the hwcomposer fix is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
62fa971 added the crate to `members` but not `default-members`, so the bare
`cargo build --release` behind `shepherd build` and `shepherd package deb`
skipped it. `install waydroid` then died with "shepherd-waydroid-helper not
found ... run 'shepherd build --release' first" — advice that could never work,
because that is the command that was skipping it.

Consequences beyond the failed install: every .deb built from this branch
shipped without the privileged helper, so a packaged kiosk had no working
force-stop, preboot, boot-completed, or lock-down path. Its sibling
shepherd-firewall-helper was in the list all along, which is why only the
Waydroid side broke.

Verified: `cargo build --release` now produces the binary, and
`DESTDIR=... install_waydroid_assets` stages the helper plus both polkit assets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
ci: test and lint the whole workspace, not just the default members (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 19s
CI / Arch neutrality (pull_request) Successful in 18s
CI / ShellCheck (pull_request) Successful in 21s
CI / CI image (pull_request) Successful in 28s
CI / CI image (Android) (pull_request) Successful in 7m25s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 21s
CI / Android DPC (build apk) (pull_request) Successful in 22s
CI / Warm cargo registry (pull_request) Successful in 49s
CI / Android companion (unit tests) (pull_request) Successful in 1m55s
CI / Android media (cargo-ndk build) (pull_request) Successful in 2m27s
CI / Firewall E2E (pull_request) Successful in 4m36s
CI / Clippy (pull_request) Successful in 3m31s
CI / Package (.deb smoke build) (pull_request) Successful in 3m35s
CI / Build (pull_request) Successful in 7m46s
CI / E2E (pull_request) Successful in 8m20s
CI / Test (pull_request) Successful in 12m49s
3a5924c077
The workspace sets `default-members`, and `shepherd-wire-codegen` is not in it,
so CI's `cargo test --all-targets` and `cargo clippy --all-targets` both skipped
that crate entirely. The rpc-codegen drift guard — the one thing that catches
checked-in generated output going stale against the Rust wire types — therefore
never ran in CI, and the crate was never linted.

That is not hypothetical: the origin/main merge on this branch left
WireTypes.generated.kt stale (missing the Android EntryKind and
SessionInfo.kind_tag) while the full suite reported green. It was caught by
hand, not by CI.

Switch both jobs to --workspace and document why in CONTRIBUTING, so a crate
kept out of default-members in future is still tested and linted.

  test binaries 42 -> 46, drift test occurrences 0 -> 1
  clippy --workspace --all-targets -- -D warnings: clean

Verified the guard now bites: appending a line to WireTypes.generated.kt makes
`cargo test --workspace --all-targets` fail on codegen_outputs_match_checked_in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
The locktask full-UI surface renders the *whole* Android display, system chrome
included. 89a4cce made it deliberately non-fullscreen so it maximized around the
HUD's exclusive zone — which pushed Android's own status bar (clock, battery,
rotation icons, all of which the HUD already shows) into view as a redundant
strip directly beneath the HUD.

Fullscreen it instead, matching how the per-app `waydroid.<pkg>` toplevels are
already handled: the surface sits under the HUD and the HUD occludes Android's
chrome. 89a4cce's concern was that fullscreen let the HUD obscure the app, but
the strip it covers is exactly the chrome we want gone — and the per-app path
has relied on that same occlusion all along.

Verified on the headless rig at 1920x1080 (locktask config, screenshots):
before, an Android status bar strip sits between the HUD and the app; after, the
app content starts immediately below the HUD with no strip. The surface maps
fullscreen at 1920x1080@0,0 from the start, so there is no letterbox band —
toggling fullscreen on an already-mapped surface does leave one, because
Waydroid does not resize its buffer on a warm geometry change.

Also drops the now-contradictory "opposite of the locktask surface" note from
the per-app block, since both are fullscreen for the same reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
A heavy app (Khan Academy) launched after a boot came up padded at the top and
clipped at the bottom until its window was maximized by hand.

The privileged helper's `maximize` refuses to resize a task that is not the
resumed one, and the adapter called it exactly once, right after the Wayland
toplevel mapped — asserting in a comment that "the window is mapped (and thus
the task is on top) by here". That does not hold: the toplevel maps as soon as
the app has a surface, which for a slow starter is well before its activity
reaches RESUMED. The single attempt returned failure, which is only logged at
debug, and nothing retried — so the task kept Waydroid's default freeform
bounds.

Retry until it takes, bounded, in the background (the app is on-screen either
way, and blocking the launch on a slow starter would only stall the session
bookkeeping behind it). Then re-apply once after a settle: a resize that lands
while a splash or launcher activity is still in front can be undone when the
real activity takes the task back to the freeform bounds. Re-applying the same
bounds is a no-op when it already took, so this covers both orderings.

`waydroid::maximize` now reports whether the resize was applied.

NOT reproduced locally — Khan Academy isn't installed on the bench, and the
image's light apps win the race. The two failure orderings above are both
consistent with the report; the retry covers either. To tell them apart on a
real device, check shepherdd's debug log for whether the first maximize attempt
succeeded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
Reported: the *second* launch made Android visibly reboot, showing the boot
animation in place of the app.

`repin_waydroid_resolution` fires on the first display-change event and treats
`pinned_mode == None` as a resolution change. At startup that beats preboot to
pinning the mode, so the repin stops the session out from under preboot's boot
and clears `scale1_booted`. Downstream that is either a wedged Android (observed
on the rig: `sys.boot_completed` never set), or a `scale1_booted` left false so a
later launch pays the native-scale restart — a full Android reboot, with the
freshly-mapped app surface showing the boot animation. 5dcfa8c made this likelier
by adding two output-scale flips inside exactly that window.

Take `waydroid_recovering` across the whole preboot boot. Both the repin and
`spawn_android`'s native-scale restart already bail when it is held, which is
right here: preboot is booting at native scale and pins the mode itself. Only
claim the native-scale boot if we actually owned the session throughout.

Verified on the rig, cold container, config-time `output * scale 1.5`:

  before  preboot logged "Display changed; restarting Waydroid at the new
          resolution" mid-boot; one run left Android wedged
  after   no repin restart during preboot; launch 1 7.7s, launch 2 5.6s,
          zero native-scale restarts across both launches

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
Diagnosed from the kiosk's own journal, which shows the real ordering:

  01:30:03  un-gating Android activities            <- launchable
  01:30:11  Restarting Waydroid at native scale 1   <- a launch landed here
  01:30:30  Waydroid pre-boot complete              <- flag only set now

`boot_completed` flips tens of seconds before preboot finishes, so Android
un-gates while `waydroid_scale1_booted` is still false. A launch in that gap
pays the native-scale session restart: a visible Android reboot with the boot
animation in place of the app. aeb9f1f's guard made such a launch *skip* the
restart while still leaving the flag false, which moved the reboot to the next
launch — the reported "second launch restarts".

Two causes, both fixed:

- Gate Android on preboot completion, not just on the session being up. New
  `waydroid_preboot_done` (starts true, so a no-preboot build is never gated
  forever) is set false before the spawn and true on every exit path.
- Preboot adopting an already-running session never proved its boot scale, so
  the flag stayed false indefinitely (visible at 01:50 in the same journal:
  restart *after* pre-boot complete). Restart an adopted session when a
  fractional scale was in play — paid while Android is still gated, instead of
  deferred into the child's first launch. When no scale drop was needed the
  output is already native, so any boot latches scale 1 and no restart happens.

Verified on the rig, cold container, config-time `output * scale 1.5`:

  before  un-gating preceded pre-boot complete by ~27s
  after   pre-boot complete 11:57:03, un-gating 11:57:06 — correct order
          launch 1 5.3s, launch 2 4.7s, zero native-scale restarts

Note the rig exercises shepherd-admin's Waydroid instance; Waydroid keeps Android
data per user, so the kiosk's own instance (which has Khan Academy) is separate.
The gate ordering is structural and applies to both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
Ending a locktask session restarted Android: the child saw the LineageOS boot
animation in place of the app on the next launch. Confirmed from the container's
own init log —

  unlock
  force-stop --package org.khanacademy.android
  init: Sending signal 9 to service 'surfaceflinger' (pid 100) process group...
  init: Sending signal 9 to service 'zygote' (pid 73) process group...
  init: starting service 'surfaceflinger'...
  init: starting service 'bootanim'...

`stop_android` destroyed the full-UI `Waydroid` surface (sway `kill`, plus
killing the `show-full-ui` child). That surface *is* Android's display
connection, so tearing it down takes surfaceflinger and zygote with it and init
restarts them. The old comment's claim that "killing the show-full-ui child
doesn't destroy the surface (the renderer is detached)" does not hold.

Park it on a dedicated `__shepherd_parked` workspace instead, keeping the client
alive and off screen, and unpark + re-fullscreen it on the next launch. The
client is now a singleton that outlives sessions rather than a per-session child,
and the exit watch no longer kills it. `android_window_id` treats parked as gone,
so the watch still observes the surface disappear and emits Exited unchanged —
no new event plumbing.

Verified on a locktask rig (cold container, `output * scale 1.5`), launch →
stop → launch:

  stop      surface lands on workspace __shepherd_parked (alive, off screen)
  init log  no surfaceflinger / zygote / bootanim restart at all
            (previously all three, every stop)
  lifecycle Session ended still fires; launch 2 presents the app, not bootanim

Note the rig has no DPC device owner, so Lock Task does not actually pin there;
what is verified is the surface lifecycle and that Android's display stack
survives a stop, which is the reported fault.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
On the first launch the child briefly saw Android's home screen before the app
appeared. The full-UI surface renders the *whole* Android display and was
presented as soon as it mapped, while the DPC's Lock Task pin only landed a
couple of seconds later (pin, settle, pin) — so the gap was on screen.

Reorder: keep the surface off screen until it is pinned, then reveal it. The
surface now maps straight onto the parked workspace (sway.conf rule) rather than
on screen, so there is no window to race; a reused surface is already parked.
shepherd waits for it to exist, pins, and only then unparks + re-fullscreens it,
making the first frame the child sees the pinned app.

Needed a parked-aware wait (`wait_for_parked_full_ui`), since the on-screen check
deliberately treats parked as gone. The post-reveal `wait_for_android_window`
still confirms it actually presented, and parks rather than kills on failure.

Verified on the locktask rig, sampling the on-screen surface every 0.5s across a
launch: the surface is invisible for the whole pin window and only becomes
visible at t=7s, landing on workspace 1. It also composites correctly after being
mapped parked — the real risk with this approach, since a surface that never got
frame callbacks could have come back blank.

The rig has no DPC device owner, so pinning is a no-op there; what is verified is
the ordering and that the reveal renders. That the revealed frame is the *pinned
app* needs a device with the DPC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
fix(android): park the locktask surface before tearing the app down (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 14s
CI / Arch neutrality (pull_request) Successful in 12s
CI / ShellCheck (pull_request) Successful in 17s
CI / CI image (pull_request) Successful in 24s
CI / CI image (Android) (pull_request) Successful in 22s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 13s
CI / Warm cargo registry (pull_request) Successful in 21s
CI / Android DPC (build apk) (pull_request) Successful in 17s
CI / Android media (cargo-ndk build) (pull_request) Successful in 41s
CI / Android companion (unit tests) (pull_request) Successful in 2m6s
CI / Clippy (pull_request) Successful in 1m47s
CI / Build (pull_request) Successful in 3m41s
CI / Firewall E2E (pull_request) Successful in 4m37s
CI / E2E (pull_request) Successful in 3m54s
CI / Test (pull_request) Successful in 4m37s
CI / Package (.deb smoke build) (pull_request) Successful in 2m21s
4fe3aa4ea6
Ending a session showed the child Android's home screen for a beat. `unlock` and
`force-stop` drop Android back to its launcher, and the full-UI surface — which
renders the whole Android display — was still on screen while those two helper
calls ran.

Park first, then unlock and force-stop, so the last frame shown is still the app.
Mirrors the launch side, where the surface is now pinned before it is revealed.

Verified on the locktask rig: after a stop the surface sits on
__shepherd_parked, and `Session ended` still fires — parking earlier does not
race the exit watch (an earlier Exited is harmless, since stop_current has
already ended the session and the engine no-ops).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
5dcfa8c held every output at scale 1 for the whole preboot session boot, so on a
cold start the launcher and HUD rendered physically small for ~60s. Android takes
its display geometry early in the boot, not at boot-complete, so that hold was far
longer than it needed to be.

Measured on the rig (1920x1080, config-time `output * scale 1.5`, cold container),
against the two known controls:

  scale 1 for the whole boot   -> wm size 1920x1080   (correct, ~60s small UI)
  booted at scale 1.5          -> wm size 1920x999    (logical x scale, wrong)
  scale 1 for ~8s only         -> wm size 1920x1080   (correct)

So restore concurrently: hold native scale until the session reports RUNNING plus
a 5s settle, then put the scale back while the boot finishes. Bounded by
boot_ready_timeout so a session that never starts can't strand the UI at scale 1.

Also restructured so an adopted session is restarted *before* the timed boot,
leaving exactly one boot to hold scale across — previously the prop/scale restart
ran after the hold had ended, which would have booted it at the fractional scale.

Verified end to end through the real preboot path: the output is back at 1.5 fast
enough that 1s sampling barely catches it (was the full boot), and `wm size` still
lands at 1920x1080.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
d935c7d restored the output scale on "session RUNNING + a fixed 5s", which is a
guess sized to one machine: on slower hardware the restore could beat Android
taking its display geometry, and the failure is silent (`wm size` comes back
`logical x scale` and nothing logs).

`waydroid.display_scale` is written by the in-container hwcomposer once it has
read the output's scale, and is readable as the session user via
`waydroid prop get` — so wait for it to report 1.0 instead. Probed on a cold
session boot: unreadable at t=0 and t=9s, readable at t=15s reporting 1.000000.
Falling back to restoring anyway (with a warning) when it never appears, capped
by boot_ready_timeout.

Verified through the real preboot path: signal observed (no fallback warning),
output back at 1.5, `wm size` 1920x1080.

KNOWN GAP, worth resolving before merge: the check cannot tell a value written by
*this* boot from one left over from a previous one. In the verification run it
matched after ~2s rather than the ~15s the cold probe measured, which is
consistent with reading a stale 1.0. Clearing the prop before the boot — so a
match can only come from this boot — would close that, and needs its own probe to
confirm the hwcomposer rewrites it rather than leaving it empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
Makes the wait in 00b7364 unambiguous: `waydroid.display_scale` is cleared before
the boot, so a 1.0 reading can only come from a write by *this* boot.

Probed to confirm a clear is safe rather than assumed:

  before clear  ''          (a cold container starts with it empty)
  after clear   ''
  rewritten     t=21s '1.000000'

That also corrects the staleness worry recorded in 00b7364. There was no stale
value: a cold container has the prop empty, so the ~2s match seen there was a
genuine write — the patched hwcomposer tracking a live scale change on an
already-warm Android (~3s, matching its validation notes), versus ~15-21s for a
first write on a cold boot. The clear still matters for the adopted-warm-session
path, where a 1.0 left by an earlier session could satisfy the check before our
own drop had been observed.

Verified end to end: signal observed (no fallback warning), scale restored to
1.5, `wm size` 1920x1080.

TUNING NOTE for review: waiting for the real signal costs ~23s of scale-1 UI on a
cold boot, against ~6s for the timer it replaced — which also produced correct
geometry. That suggests the geometry may not depend on the hwcomposer's scale
read at all (the pinned resolution props may do the work), in which case this is
holding conservatively longer than needed. Settling that safely means adding the
post-preboot `wm size` check against the physical mode and then shortening, so a
too-early restore self-corrects instead of failing silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
Everything around the native-scale hold — how long to hold it, when to end it —
is inference about when Android reads the output scale. This checks the outcome
instead of trusting the timing.

After preboot, compare Android's display size against the primary output's
physical mode. On a mismatch, drop the `scale1_booted` claim and warn: the first
scaled launch then re-verifies the slow-but-correct way (a session restart at
scale 1). So a too-early restore self-corrects rather than silently shipping a
wrongly-sized Android — the failure mode this whole area keeps producing, and the
one thing none of the previous fixes could detect.

Adds a read-only `display-size` verb to the privileged helper (`wm size` needs
root). No caller-controlled input, so it adds no argument-validation surface.

Verified: the new verb returns `Physical size: 1920x1080`, and a real preboot run
logged no mismatch, agreeing with `wm size` directly.

NOTE: end-to-end the check needs the *new* helper installed. The rig ran against
the previously-installed /usr/libexec build, which rejects `display-size`, so
`display_size()` returned None and the check no-op'd (its `(Some, None)` arm logs
at debug). That is the intended degradation on a version skew — it never
invalidates a good claim — but it does mean the mismatch branch itself is
untested against a real wrong-size boot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
docs(android): scope showing the loading screen during Android preboot (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 11s
CI / Arch neutrality (pull_request) Successful in 11s
CI / ShellCheck (pull_request) Successful in 15s
CI / CI image (pull_request) Successful in 23s
CI / CI image (Android) (pull_request) Successful in 21s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 10s
CI / Warm cargo registry (pull_request) Successful in 21s
CI / Android DPC (build apk) (pull_request) Successful in 14s
CI / Android media (cargo-ndk build) (pull_request) Successful in 32s
CI / Android companion (unit tests) (pull_request) Successful in 2m5s
CI / Clippy (pull_request) Successful in 1m42s
CI / Build (pull_request) Successful in 3m41s
CI / Firewall E2E (pull_request) Successful in 4m27s
CI / E2E (pull_request) Successful in 3m56s
CI / Test (pull_request) Successful in 4m31s
CI / Package (.deb smoke build) (pull_request) Successful in 2m23s
f5ba1b1dd3
Handoff for the last user-visible rough edge before review: the ~15s window at
boot where the launcher and HUD render at scale 1 while Waydroid takes its
display geometry.

Records the measured timings, what already exists (waydroid_preboot_done, the
launcher's loading page), the proposed HostEvent -> wire event -> launcher chain,
the two open decisions (whether the HUD gets a startup state; whether the event
is Waydroid-specific or generic), and the verification cases — including the two
easy ways to break it: a config with no Android entries, and a preboot failure
path leaving the loading screen up forever.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GMjVNqAdqcVz6j7b9dwM6H
feat(android): cover the screen while Waydroid preboots (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 14s
CI / Arch neutrality (pull_request) Successful in 12s
CI / ShellCheck (pull_request) Successful in 17s
CI / CI image (pull_request) Successful in 28s
CI / CI image (Android) (pull_request) Successful in 28s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 14s
CI / Warm cargo registry (pull_request) Successful in 22s
CI / Android DPC (build apk) (pull_request) Successful in 17s
CI / Android media (cargo-ndk build) (pull_request) Successful in 43s
CI / Android companion (unit tests) (pull_request) Successful in 1m52s
CI / Clippy (pull_request) Successful in 1m54s
CI / Build (pull_request) Successful in 3m34s
CI / Firewall E2E (pull_request) Successful in 4m28s
CI / E2E (pull_request) Successful in 3m54s
CI / Test (pull_request) Successful in 4m43s
CI / Package (.deb smoke build) (pull_request) Successful in 2m24s
b863d42e6e
Preboot holds every output at scale 1 for ~15s while Android latches its
display geometry, and the launcher grid and HUD sit there rendering physically
smaller for the duration. Cover it with the launcher's existing loading page.

Implements docs/ai/history/2026-07-30 001 startup-loading-screen-scope.md, with
two deliberate departures:

Snapshot field, not an event. The scope proposed EventPayload::StartupBusy, but
a one-shot event loses a startup race it would hit on the *normal* path: preboot
starts with shepherdd and the launcher connects a beat later, so it would never
learn. ServiceStateSnapshot.startup_busy re-broadcast on each transition (how
kind readiness already works) is correct for late subscribers and reconnects,
and is one wire change instead of two. HostEvent::StartupBusy remains as the
host -> daemon leg.

PrebootGate rather than "remember to emit on every path". The scope's top gotcha
was that a missed exit path strands the kiosk on a loading screen forever, so
the readiness flag and the event are now one RAII guard: closed synchronously
before the preboot task spawns, opened idempotently where the flag used to be
stored, and opened again on Drop so an early return or a panicking task still
clears the cover.

Also collapses the launcher's three copies of "apply a snapshot" onto
SharedState::apply_snapshot. The first attempt showed the grid despite
startup_busy: true on the wire, because the initial service_state fetch had its
own copy that ignored the new field.

Open decisions from the scope: the mechanism is generic but Waydroid is its only
emitter (the Steam preload is slow but invisible, so a cover there would be a
regression), and the HUD is left live above the loading page, as it already is
for Connecting and Launching.

Verified on the headless rig, all four scope cases: cover up during a real
preboot and grid back after "pre-boot complete" (with Android un-gated);
preboot = false shows the grid normally; and a stub `waydroid` that fails
`session start` still clears the cover, leaving Android correctly gated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASwxYniDSdnMPxKAyyJGgZ
`shepherd-admin apps install android` resolves the DPC apk through
get_data_dir(), which is /usr/share/shepherd on any install that isn't a source
checkout — but only package.sh put it there. A from-source install therefore ran
shepherd-admin out of /usr/local/bin, found no apk, and told the operator to run
dpc-waydroid/build.sh, which writes it somewhere the installed admin still won't
look.

install_dpc_apk() now stages the apk (+ its .version sidecar) into the data dir,
called from install_system — the shared list the .deb stages through under
DESTDIR — so the package and a from-source install place it identically, and
package.sh's bespoke copy is gone. uninstall_dpc_apk mirrors it, leaving an
already-provisioned device owner (which lives inside Android) alone. Both are
exposed as `shepherd install dpc` / `uninstall dpc` so "build it, then stage it"
doesn't mean re-running `install all`.

It never builds the apk. The signing key is persistent — a provisioned device
owner only accepts updates from the same key — so auto-building during packaging
would mint a throwaway keystore and produce a .deb that can never update a real
device. Missing apk is a warning at install time (locktask is opt-in; everything
else installs fine) and a hard stop with instructions at provisioning time,
branching on whether the data dir is a checkout: in-tree says "build it and
re-run", an installed host gets the build-then-`install dpc` route.

Fixes a latent bug found while testing those prompts: install_dpc's two version
lookups aborted the script silently under `set -euo pipefail` — an
`[[ -f x ]] && assign` whose test fails returns 1, and the versionName grep
matches nothing on a device that has no DPC yet, which pipefail turns into a
failed assignment. Both exited rc=1 before any message printed, making the
existing "apk not found" die unreachable and breaking the first provision of any
device. Both reads are "absent is an answer, not an error" now.

Verified: `package deb --no-build` produces a .deb containing
./usr/share/shepherd/shepherd-dpc.apk + sidecar; install/uninstall round-trip
through a DESTDIR tree; both missing-apk prompts print and exit 1 against a
stubbed session, while a matching .version short-circuits to "already device
owner"; shellcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASwxYniDSdnMPxKAyyJGgZ
A package missing dpc-waydroid/shepherd-dpc.apk silently loses Android's
`lock_mode = "locktask"` on every device it installs, and nothing says so until
an operator runs `apps install android` on the device — the worst place to find
out. Make it fatal at package time instead.

`shepherd package deb` now dies when the apk is absent, naming both the build
command and the override; --allow-missing-dpc downgrades it to a warning. The
check runs before the release build so a local run fails in a second rather than
after a full compile, and the flag is forwarded through the unprivileged fakeroot
re-exec — which re-runs the same check and would otherwise fail a build that was
explicitly allowed.

Packaging still never builds the apk: it is signed with a persistent key that a
provisioned device owner can only be updated from, so building it stays an
explicit out-of-band step.

CI's packaging smoke job passes the flag — it runs in the base image with no
Android SDK, publishes nothing, and the apk's own build is covered by the dpc
job. The release job keeps the default and fails: its DPC step now errors when
SHEPHERD_KEYSTORE_B64 is unset rather than continuing, so the diagnosis lands on
the missing secret instead of on packaging. A deliberate DPC-less release is the
new `allow_missing_dpc` workflow_dispatch input, which gates both that step and
the flag; a tag push has no inputs and so always takes the strict path.

Verified: apk present -> .deb contains it; apk absent -> refuses, rc=1; absent
with --allow-missing-dpc -> builds a .deb with no apk in it. Both workflows
parse; shellcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASwxYniDSdnMPxKAyyJGgZ
shepherd's fast Android reopen assumes a patched in-container hwcomposer, but
that requirement lived only in 204a391's commit message and the upstream brief:
nothing installed it, nothing detected it, and docs/INSTALL.md never mentioned
it. On a fractional-scale panel with the stock hwcomposer the first launch looks
perfect and the second renders at half size in the top-left — and Android-side
state stays correct throughout, so nothing shepherd can query catches it.

- docs/INSTALL.md: a "step 0" for HiDPI panels — who it applies to (non-integer
  `output * scale` only), why shepherd's scale dance triggers the latch, the
  misleading symptom, the one-line check for whether it is installed, and the
  patch itself (tarball with install/uninstall scripts) at issue #119.
- `shepherd-admin apps install android` reports the state on every run, last so
  it doesn't scroll away: present -> one info line; absent -> the symptom, the
  link, and the path that decides it. Detected by the overlay file, which stock
  Waydroid doesn't ship and nothing else shepherd installs writes to.
- The upstream brief now leads with the patch being installable today, and the
  adapter records the assumption where the restart is skipped, so a future
  refactor of `waydroid_scale1_booted` sees why a warm session is trusted.

An upstream Waydroid PR is pending; all three pointers say so, since this step
goes away if it lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASwxYniDSdnMPxKAyyJGgZ
Clean forward merge of c3204f0 (the HUD confirm-button XWayland DPI fix): no
conflicts, and rpc-codegen re-run produced no diff — main touched no wire type
and the branch's own startup_busy regen was already committed. Running it anyway
is the previous merge's lesson, since generated outputs are never in the conflict
set and only the drift test catches staleness.

Notes the one new environment gotcha: the companion Gradle build needs
ANDROID_HOME=/opt/android-sdk here, or it fails at configuration time with "SDK
location not found".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASwxYniDSdnMPxKAyyJGgZ
24561f3 added the `display-size` verb but left USAGE listing the other ten, so
an unknown-subcommand error tells the operator the verb doesn't exist — which is
indistinguishable from the real failure mode this area has (an old helper
installed at /usr/libexec that genuinely predates the verb). Hit exactly that
while checking whether this box's helper was current: the usage said no, the
binary said yes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASwxYniDSdnMPxKAyyJGgZ
The in-kiosk suite hadn't run since af4af6a — before the preboot, native-scale,
and locktask work — and three defects in the harness and tests made correct
product code look broken. All four tests now pass against real Waydroid.

1. The orchestrator's nested sway config was a hand-written copy of the
   production window rules and had drifted: when the locktask path started
   PARKING the full-UI surface before pinning, sway.conf gained `move container
   to workspace __shepherd_parked` and the copy did not, so
   wait_for_parked_full_ui never matched and spawn failed with "Waydroid full UI
   did not appear within 45s". It now lifts those rules out of sway.conf with a
   grep instead of restating them, and prints what it used.

2. The orchestrator set persist.waydroid.multi_windows AFTER stopping the
   session. `waydroid prop set` reaches the property service through the session
   and silently exits 0 without one, so the prop stayed true; the test's own
   preboot then had to restart the session, and a session started from the test
   binary's minimal environment can't resolve the third-party DPC activity. A
   silent no-op surfaced three layers away as "Lock Task never LOCKED". Now set
   before the stop, and verified — one clear line instead of a cascade.

3. The post-stop assertion still demanded the full-UI surface be GONE, the
   contract 593a904 deliberately replaced: that surface is Android's display
   connection, and destroying it takes surfaceflinger and zygote with it, so stop
   parks it and keeps the client alive. It now asserts parked-on-HIDDEN_WORKSPACE
   via list_windows — the same query the adapter uses — instead of a substring
   match on the sway tree that cannot see workspaces. Re-exports list_windows and
   HIDDEN_WORKSPACE for it; sway.conf already hardcodes that workspace name with
   a comment to keep the two in sync.

The suite also now warns on exit that it leaves multi_windows=false with no
session left to set it through, because that is precisely what makes the NEXT
cold run fail in test 1 after a four-minute poll.

Not fixed here, and reported separately: preboot_waydroid's own prop pins hit the
same silent no-op on a cold container, which is a real product bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASwxYniDSdnMPxKAyyJGgZ
fix(android): apply preboot's prop pins after the session exists (#2)
All checks were successful
CI / Version harmony (pull_request) Successful in 13s
CI / Arch neutrality (pull_request) Successful in 12s
CI / ShellCheck (pull_request) Successful in 16s
CI / CI image (pull_request) Successful in 27s
CI / CI image (Android) (pull_request) Successful in 22s
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 13s
CI / Warm cargo registry (pull_request) Successful in 20s
CI / Android DPC (build apk) (pull_request) Successful in 16s
CI / Android media (cargo-ndk build) (pull_request) Successful in 49s
CI / Android companion (unit tests) (pull_request) Successful in 1m50s
CI / Clippy (pull_request) Successful in 1m57s
CI / Build (pull_request) Successful in 3m42s
CI / Firewall E2E (pull_request) Successful in 4m30s
CI / E2E (pull_request) Successful in 3m52s
CI / Test (pull_request) Successful in 4m42s
CI / Package (.deb smoke build) (pull_request) Successful in 2m25s
76b3f9de95
`waydroid prop set` reaches the property service through the SESSION, and
without one prints "WayDroid session is stopped" and exits 0 — so set_prop
reported success. preboot_waydroid set its pins (multi_windows, width, height)
before starting the session, so on a cold container they silently did nothing.
shepherdd stops the prebooted session at shutdown, making that every daemon
start: the pins never applied at all. Invisible while the persisted values
already matched, permanent when they didn't — a fresh container, or an operator
switching lock_mode back from "locktask", left multi_windows wrong forever and
the statusbar launch path with no per-app toplevel to track.

Re-apply after the boot, when the props are writable, and boot once more if
anything actually changed (they are read at session start, so the running session
still holds the old values). The boot block is now boot_session_at_native_scale
so both boots get identical treatment — scale-1 hold, display_scale clear,
concurrent restore — rather than the second being a hand-copied variant. The
corrective restart sits inside the restart-guard window, and `adopted` accounts
for it, so a corrected boot still claims native scale: we owned it outright.

How often the extra boot fires, measured on the rig (two runs each):

  props already correct, warm adopt   no correction    3.4s / 4.4s
  props stale, cold container         one restart     91.09s / 91.16s

Only when the desired props differ from what is persisted — a fresh container, a
lock_mode/multi_window change, or the locktask path having flipped multi_windows
for its own session. The values persist, so the next start finds them correct and
skips it. Not new overhead on a working path: the 91s case previously never
converged. (This VM boots Android in ~45s software-rendered; a warm boot on real
kiosk hardware measured 4-6s earlier in this branch.)

New waydroid_preboot_fixes_stale_props_on_a_cold_container encodes the bug, not
the fix: it poisons the prop through a live session (the only way to write one —
the bug in miniature), goes cold, and asserts preboot still converges. It reports
the elapsed time, which is where the numbers above come from.

Verified: all five integration tests pass against real Waydroid on HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ASwxYniDSdnMPxKAyyJGgZ
All checks were successful
CI / Version harmony (pull_request) Successful in 13s
Required
Details
CI / Arch neutrality (pull_request) Successful in 12s
Required
Details
CI / ShellCheck (pull_request) Successful in 16s
Required
Details
CI / CI image (pull_request) Successful in 27s
Required
Details
CI / CI image (Android) (pull_request) Successful in 22s
Required
Details
CI / CI images (pull_request) Successful in 0s
CI / Rustfmt (pull_request) Successful in 13s
Required
Details
CI / Warm cargo registry (pull_request) Successful in 20s
CI / Android DPC (build apk) (pull_request) Successful in 16s
CI / Android media (cargo-ndk build) (pull_request) Successful in 49s
Required
Details
CI / Android companion (unit tests) (pull_request) Successful in 1m50s
Required
Details
CI / Clippy (pull_request) Successful in 1m57s
Required
Details
CI / Build (pull_request) Successful in 3m42s
Required
Details
CI / Firewall E2E (pull_request) Successful in 4m30s
Required
Details
CI / E2E (pull_request) Successful in 3m52s
Required
Details
CI / Test (pull_request) Successful in 4m42s
Required
Details
CI / Package (.deb smoke build) (pull_request) Successful in 2m25s
Required
Details
This pull request has changes conflicting with the target branch.
  • CONTRIBUTING.md
  • crates/shepherd-firewall-bpf/Cargo.lock
  • scripts/lib/admin.sh
  • scripts/lib/install.sh
  • scripts/lib/package.sh
  • scripts/shepherd
  • scripts/shepherd-admin
View command line instructions

Manual merge helper

Use this merge commit message when completing the merge manually.

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin u/albert/2/android-activity:u/albert/2/android-activity
git switch u/albert/2/android-activity
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
albert/shepherd-launcher!75
No description provided.