Add support for Android activities #75
No reviewers
Labels
No labels
bug
duplicate
enhancement
future
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
albert/shepherd-launcher!75
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "u/albert/2/android-activity"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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:
never land on a boot animation.
~7s rather than ~80s.
status bar and adds a back button in place of the caption bar the child
must not reach.
lock_mode = "statusbar"(default) disablesthe notification shade and nav-bar home/recents;
"locktask"pins the app inAndroid Lock Task Mode via a Device Owner DPC;
"off"disables both.full volume range and the UI isn't half-size on a HiDPI panel.
Configuration
Setup is
shepherd-admin apps install android <user>— seedocs/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_androidin theadapter, plus a
waydroidmodule wrapping the CLI. Sessions are tracked by theirWayland toplevel (
waydroid.<pkg>, or the singleWaydroidfull-UI surfaceunder 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, groupshepherd-waydroid) for the operationsthat 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 apackage name, validated by a shared
shepherd-utilpredicate and passed as asingle argv element.
dpc-waydroid/— a minimal Device Policy Controller (Device Owner) for LockTask Mode. Built by a plain SDK pipeline, signed with the shared org key, shipped
inside the
.deb, and installed byshepherd-admin apps install android.Readiness gating reuses the Steam mechanism from #76:
HostEvent::KindReadinessChangedhides 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.mdis 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 setreachesthe 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::AndroidandEntryKind::Android { package_name, args }, plusServiceStateSnapshot.startup_busy(shells cover the screen while a startup stepdisrupts it). Generated Kotlin/TS outputs regenerated;
docs/rpc-schema.jsonupdated. 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
argument validation, and the
PrebootGateinvariants.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.
the
headless-devharness with screenshots.CI is green:
cargo test --workspace --all-targets,clippy -D warnings,fmt,shellcheck, arch-neutrality,
.debsmoke build, web-UI typecheck, and thecompanion/DPC/media Android jobs.
Known limitations
there. amd64 needs libndk for ARM-only apps; arm64 runs them natively.
certification can't be automated; the admin command guides and reports.
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:2026-06-28 001 android-activity-kind-scoping.md— why Waydroid, what the kind is2026-06-28 004 android-phase2-runtime-slice.md— spawn/stop/window tracking2026-06-28 006 android-force-stop-helper-and-preboot.md— the privileged seam2026-06-28 009 android-dpc-lock-task.md+2026-07-12 002 default-managed-android-config.md— lock-in and the DPC2026-07-17 001 waydroid-fractional-scale-diagnosis.md+2026-07-29 003 android-first-open-native-scale-preboot.md— the scale saga2026-07-30 002/003/005/006— loading screen, DPC packaging, the integration-suite re-run, and the prop-pin fixAdds `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_01JqjouzyF5uAxbAkXPTKU2tImplements 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_01JqjouzyF5uAxbAkXPTKU2tu/albert/2/android-activityto Add support for Android activities`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_01FBpVHcoVeTgjo5i3vMiXpfThe 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_01FBpVHcoVeTgjo5i3vMiXpfinstall_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_01FBpVHcoVeTgjo5i3vMiXpfwaydroid app launchso a wedged session can't hang the launcher (#2) 73ccd6556dBuilds 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_01FBpVHcoVeTgjo5i3vMiXpfOn 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_01FBpVHcoVeTgjo5i3vMiXpfOn `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_01FBpVHcoVeTgjo5i3vMiXpfOn 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 frome938a1fas seen on leibniz. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FBpVHcoVeTgjo5i3vMiXpfMeasured 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_01GMjVNqAdqcVz6j7b9dwM6HDiagnosed 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_01GMjVNqAdqcVz6j7b9dwM6HEnding 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_01GMjVNqAdqcVz6j7b9dwM6HView 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.