# Agent architecture — bakeoff conclusions (MDMesh device agent) Synthesis of the Headwind teardown (`reference/hmdm-android/`, Apache-2.0) + current Android docs. Verdict per area: **take** (port the pattern), **modernize** (keep idea, new impl), **build-new**. ## App installation — MODERNIZE Headwind's design Headwind's serialized PackageInstaller pump (queue → commit one → result broadcast pumps the next) is the *correct* shape and the fix for the classic "only first app installs then stalls" bug. We keep the design, drop the implementation: - **Take:** version policy (prefer `versionCode`; `"0"` = any), downgrade-requires-explicit-remove, persisted attempt-gating to break download/install loops, the Intent-Redirection mitigation for `STATUS_PENDING_USER_ACTION`, split-APK (sum sizes + `setSize` + per-split `openWrite`). - **Modernize:** `AsyncTask` recursion → one `suspend` loop that `suspendCancellableCoroutine`s on each session's result; `HttpURLConnection` → OkHttp; result receiver → **manifest-registered**, explicit-intent, `FLAG_IMMUTABLE`, `RECEIVER_NOT_EXPORTED`; add `setInstallReason(POLICY)` and `setRequireUserAction(NOT_REQUIRED)` (API 31+). ## Kiosk / COSU — BUILD-NEW (Headwind's real kiosk is closed "Pro") OSS only has an overlay hack; the COSU engine is stubbed in `ProUtils`. We build it the native way: - `setLockTaskPackages` + `setLockTaskFeatures(HOME | OVERVIEW | NOTIFICATIONS | …)`, `android:lockTaskMode="if_whitelisted"` on the kiosk activity, `addPersistentPreferredActivity` for HOME, and `DISALLOW_CREATE_WINDOWS` in `onLockTaskModeEntering`/clear on exit. - **Take from Headwind:** the default-launcher claim pattern (`addPersistentPreferredActivity` + HOME/LEANBACK manifest filters) and the **crash-loop protection** algorithm (SharedPreferences counter; >N crashes/60s → stop self-restart, offer a launcher chooser so a broken DPC can't brick the device). Keep the overlay lock screen only as a secondary "remote lock" UI, not for app-blocking. ## Policy — TAKE Headwind's catalog (highest-value artifact) Use `Utils.java`'s DevicePolicyManager catalog as a checklist for a typed Kotlin policy layer behind the existing capability-abstraction (`:policy`), porting some calls and adding others: user restrictions (`DISALLOW_*`), `setCameraDisabled`, `setScreenCaptureDisabled`, `setStatusBarDisabled`, `setKeyguardDisabled`, wifi/bluetooth/usb, `setPermissionPolicy(AUTO_GRANT)`, `setSystemUpdatePolicy`, suspend/hide packages, `setUninstallBlocked`. Keep the SDK-guard discipline; drop reflection hacks. Each policy advertises a capability key and is applied via a `policy.apply` command or the enroll baseline. ### Desired state (`config.apply`) Besides the one-off `policy.apply`/`kiosk.enter` commands, the server keeps each device in line with its configuration. **How it works** - On every check-in the server builds a desired-state document from the `Configuration` row (policy toggles, kiosk shape, location mode) and hashes it into a `revision`. - If that differs from the revision the device last applied, the same check-in carries one `config.apply`. No extra polling. - The agent applies each key through the existing strategies (kiosk via the same `KioskApplier` as `kiosk.enter`) and reports a per-key outcome: `applied`, `unsupported`, `failed: ` or `deferred: `. - It saves the new revision only when nothing failed, and re-applies the saved document after a boot or self-update, so a reboot can't silently undo it. - Only agents that advertise `configApply` get the command. Older ones show "Agent too old" in the console. - When a device reports a new agent version, the server re-sends `config.apply` until one queued for that version comes back done (`device_state.configAgentVersion`), so stale `unsupported` results refresh. A re-sent document with the same kiosk payload doesn't relaunch the launcher. **Kiosk.** Kiosk on in the configuration enters kiosk. Kiosk off exits it only on devices that entered via a configuration; an ad-hoc "Custom kiosk" from the console is left alone. After someone leaves the kiosk, the console's "Return to configured kiosk" sends `kiosk.enter` with the configuration's own kiosk block (the same payload `config.apply` carries), so the device is back in exactly that kiosk without waiting for a configuration change. After a PIN exit, staff can tap **Return to kiosk** in the agent to restore the latest locally stored configured kiosk, even offline; the button is available only outside kiosk on Device Owner devices whose configuration enables it. It asks first, says so when that kiosk has no exit on the device, enters only the kiosk that was confirmed, and finishes the entry even if the screen goes away (a rotation); once in kiosk it queues a check-in. A newer delivered kiosk-off configuration removes the option even if another policy failed; a remote `kiosk.exit` leaves it available while the configuration still enables kiosk. **The 25 enforced fields** | Area | Fields | |------|--------| | Kiosk | `mainAppId`, `kioskMode`, `kioskExit`, `kioskHome`, `kioskRecents`, `kioskNotifications`, `kioskSystemInfo`, `kioskKeyguard`, `kioskLockButtons`, `kioskLockVolume`, `password` (exit PIN, stored as entered) | | Launcher look | `backgroundColor`, `textColor`, `iconSize` | | Device policy | `wifi`, `wifiRadio`, `locationServices`, `bluetooth`, `camera`, `usbStorage`, `disableScreenshots`, `disableFactoryReset`, `disableSafeBoot` | | Location | `requestUpdates` (location capture mode) | | Connectivity | `connectivityMode` (live-connection mode, 1.5) | `name` and `description` are metadata only. Wire shapes: `proto/payloads/config-apply.schema.json` and `proto/payloads/config-apply-result.schema.json`. **Protocol history** (all additive; older agents keep working) - **1.1:** `config.apply` itself. - **1.2:** the `factoryReset` policy key. Agents without it report `unsupported`. - **1.3:** the `safeBoot` key, the same way. An agent updated later picks it up from the saved document on its post-update re-apply, without re-enrollment. - **1.4 (v0.3.4):** - **Do not track** (`requestUpdates = DONOTTRACK`, the column default) is sent as location mode `off`, and the agent reads no location. Older agents treat `off` as passive, so the server also drops location from check-ins of devices on a Do-not-track configuration. - **`wifiRadio`** switches the radio; `wifi` still only locks the Wi-Fi settings. See below. - **`kioskLockVolume`** blocks volume in kiosk (`DISALLOW_ADJUST_VOLUME`). Android mutes the device along with it, so it's a separate opt-in. `kioskLockButtons` blocks only the power menu. - New columns are NULL on upgrade, so revisions only change for configurations that use them, and for Do-not-track configurations (whose devices get one `config.apply`). - **1.5:** the `connectivity` section (`connectivityMode`): `adaptive` (Battery-saver, the agent's default) holds the wake socket only while the screen is on or charging; `alwaysOn` holds it 24/7, so commands reach a locked device without waiting for the next heartbeat. Applied through the same `PowerModeStore` as `device.powerMode`. NULL is not managed: the section is omitted, so existing revisions don't move, and each device keeps its mode, including one set by `device.powerMode`. A managed mode wins over `device.powerMode`: the saved document is applied again after every boot and agent update (`ConfigReapplyWorker`), and the server re-sends `config.apply` when the agent version changes, so a device-level mode lasts only until then. Switching a configuration from `alwaysOn` back to not managed leaves its devices on `alwaysOn`; set `adaptive` to switch them back. Older agents ignore it. - **1.6:** the `locationServices` policy key: Android 11+ Device Owners turn Android Location services on or off and lock the setting while managed; Not managed (NULL, the upgrade value) lifts the lock and leaves the switch as it is. It is separate from `requestUpdates`. Agents without it, and Android 10 and older, report `unsupported`. **Wi-Fi radio Off, in detail** - Off is deferred, without blocking the other keys, while Wi-Fi is the device's only network. It's retried only at the next configuration change, reboot or agent update. Phones that drop mobile data while on Wi-Fi never apply it. - If no check-in reaches the server for 30 minutes (about three heartbeats) after Off turned Wi-Fi off (SIM removed, server only reachable over Wi-Fi), the heartbeat turns Wi-Fi back on and holds it on. Off stays deferred (`deferred: no connection to the server ...`) until a document with a different revision than the last delivered one arrives. - The agent then reports an applied revision of 64 zeros, which matches no configuration, so the server re-sends `config.apply` after its normal backoff and the console shows the deferral. - A server outage that long, or a user cutting the device off, also triggers this. Any configuration change (a new revision) applies Off again. ### Configuration apps (`app.install`) `config.apply` carries no app list: the apps a configuration marks Install reach a device as `app.install` commands. - **Enrollment** queues all of them (`ConfigAppInstaller`). - **Check-in** (`AppReconciler`, agents that advertise `app.silentInstall`) compares the configuration's install set (its id and Install version ids) with the one stored for the device in `device_state.appsRevision`. Unchanged, that's the whole cost: one indexed select. When a save or Deploy's "Add to a configuration" adds an app or a new version, each version added gets an `app.install` in the same response. When the device was moved to another configuration, or has no set stored (enrolled after the update), its configuration's whole set is decided. Either way an app is skipped when the device's command history shows it installed (an install of that version or a newer one done, with no `app.uninstall` since) or in flight (pending, delivered or accepted), so enrollment's installs aren't sent twice. Saves and moves wake the devices, so this happens in seconds; a missed wake waits for the next regular check-in. - **Retries:** an install that reached the device and didn't come back done (failed, or its result was lost) is sent again 15 minutes and then an hour later, three attempts in all (`AppReconcileDecision`). One the server refused (no SHA-256 for the APK) is recorded once. Only attempts from the last 24 hours count, so an app that failed long ago and is added again is tried afresh. The new set is stored once every app decided is installed or given up on. - **Upgrade:** the update that added the column stored the current set of each device on a configuration, so it sends no installs to the fleet, and an app added after it installs at the next check-in, even on a device that was offline during the update. Apps a configuration already had are left as they are. - **Install config apps** on the device page sends every app again (`POST .../syncApps`), e.g. after one was removed on the device. Removing an app from a configuration doesn't uninstall it. - A hosted APK is hashed once per file (kept until its size or modification time changes), not once per device. ## Status UI — BUILD-NEW (Views, minSdk 24 friendly) Replace the `TextView` stub `MainActivity` with a real MDMesh status screen: managed state, device id, applied policies, kiosk on/off, last check-in, install results. `ComponentActivity` (Hilt). Drive from a `StateFlow`. Edge-to-edge handled for Android 15. ## Android 14/15 guardrails (apply throughout) Typed `foregroundServiceType`; manifest receivers + explicit intents + `FLAG_IMMUTABLE` + `RECEIVER_NOT_EXPORTED`; scoped `` (no blanket `QUERY_ALL_PACKAGES` in base — Play-Protect ADR 0005); edge-to-edge insets; don't rely on PendingIntent restart tricks (Android 15 clears them) — lean on DO + lock-task auto-launch + WorkManager. ## Module map (additions) - `:policy` — typed `PolicyManager` + full strategy catalog + capability registry (expanded). - `:kiosk` — real `KioskController` (lock-task) + `CrashLoopGuard` + kiosk activity hooks. - `:core` — `InstallManager` (coroutine pump) + new `CommandHandler`s (`app.install`/`app.uninstall`/ `kiosk.enter`/`kiosk.exit`/`device.reboot`/`device.lock`); `policy.apply` expanded; telemetry in check-in. - `:app` — status UI, manifest (install receiver, FGS types, `lockTaskMode`, queries), DI multibindings, baseline policy applied on enrollment. ## Location collection Before reading a location, the agent (as Device Owner) grants itself any missing foreground or background location permission; devices enrolled by QR code used to lack the background one (agent 0.3.8). A missing grant is retried at most every 15 minutes. The fix callback runs on the binder thread and telemetry on Dispatchers.IO, so a check-in never blocks the main thread. With no cached position, "GPS (fresh fix)" gets up to 30 seconds for a first fix at most once every 15 minutes; otherwise a fix has 5 seconds.