From 4212838824f8aae7c3d815620bf7c4d0c452c37e Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Wed, 19 Aug 2026 15:34:20 -0600 Subject: [PATCH 1/2] ADFA-5197 docs(hotspot): why LOHS credentials can't be pinned; the Wi-Fi Direct alternative --- .../docs/hotspot-credentials-lohs-and-p2p.md | 197 ++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 controller/docs/hotspot-credentials-lohs-and-p2p.md diff --git a/controller/docs/hotspot-credentials-lohs-and-p2p.md b/controller/docs/hotspot-credentials-lohs-and-p2p.md new file mode 100644 index 00000000..5e87be69 --- /dev/null +++ b/controller/docs/hotspot-credentials-lohs-and-p2p.md @@ -0,0 +1,197 @@ +# Controlling hotspot credentials on Android: what a non-system app can and cannot do + +Engineering research note. 2026-08-19. Ticket: ADFA-5197 — preserve hotspot name/password +across restarts. Context: the ADFA-4520 LOHS family. + +## TL;DR + +A regular app (no root, not platform-signed, no privileged install) **cannot set the SSID +or password** of the hotspot it raises — neither the Local-Only Hotspot (LOHS) nor the +conventional tethering / SoftAP. Those knobs are gated behind system-signature permissions. +K2Go already lives with this: `LocalHotspotManager` starts a LOHS and only **reads** the +system-generated SSID/passphrase, then hands them off by QR. + +If the goal is a **stable, self-managed local Wi-Fi that the host app controls** (predictable +enough that clients can reconnect), the only path that stays inside the public SDK and the +permission model is **Wi-Fi Direct running as an Autonomous Group Owner** (`WifiP2pManager.createGroup()`). +On our SDK floor it still cannot *pre-set* the SSID/passphrase (that overload arrived in API 29), +but the credentials it generates can be read back and, via persistent groups, tend to remain +stable across sessions — with the caveat that this stability is empirical, not contractually +guaranteed, and must be device-verified before we rely on it. + +This note is the backing for saying "no, we can't rename/secure the LOHS ourselves," and for +scoping the Wi-Fi Direct alternative if a stable local AP becomes a requirement. + +## Scope and the app's real SDK range + +The relevant constraint is: no root, no platform signature, no Google Play Services dependency. +The app is `minSdk 24 / targetSdk 28 / compileSdk 34` (`controller/app/build.gradle`). LOHS is an +API-26 feature, so `LocalHotspotManager.isSupported()` gates on `Build.VERSION.SDK_INT >= O`; on a +true Android 7.0 (API 24) device LOHS does not exist at all. So statements below are qualified by +API level rather than framed as "API 24 only". + +## How Android gates network configuration + +Wi-Fi is layered: the app calls the public `WifiManager` / `WifiP2pManager` in the app process, +which IPC (Binder) into `system_server`, where `WifiServiceImpl` / `ConnectivityService` enforce +permissions by UID before talking to the native daemons (`wpa_supplicant` for client/P2P, `hostapd` +for SoftAP). Any attempt to name or secure an AP must pass that server-side check. Since Android 7, +the AP-config path is explicitly UID/permission-checked there — which is why reflection tricks that +worked on Android 4–6 stopped working (details below). Enforcement is server-side in `system_server`, +not something the caller can evade from the app process; on API 28+ the hidden-API greylist adds a +second block to the reflected symbols. + +## Local-Only Hotspot (LOHS): read-only credentials + +The public entry point is `WifiManager.startLocalOnlyHotspot(callback, handler)`. The system picks a +random SSID and a high-entropy WPA2 passphrase and hands them to the app through the reservation. The +app can read them but not choose them: + +| Capability | Permission required | Feasible without root / platform signature | +|---|---|---| +| Read the generated SSID / passphrase | none (public callback) | Yes — this is what the app does | +| Set a custom SSID | `NETWORK_SETTINGS` | No — signature-level, OEM/platform only | +| Set a custom passphrase / BSSID | `NETWORK_SETUP_WIZARD` | No — reserved for setup/provisioning apps | +| Pin / reuse the same credentials next time | n/a | No — regenerated per reservation | + +A custom-config overload exists (`startLocalOnlyHotspot(SoftApConfiguration, executor, callback)`) but +it is `@SystemApi` (absent from the public SDK) and still requires `NETWORK_SETTINGS` / +`NETWORK_SETUP_WIZARD`; `NEARBY_WIFI_DEVICES` alone does not unlock the custom-config path for a normal +app. The official developer guide only documents the no-config overload. Net: for K2Go, LOHS SSID and +password are **read-only and non-stable** (they change each time the hotspot comes up). + +How we use it today: `LocalHotspotManager.start()` (`controller/.../hotspot/LocalHotspotManager.java:122-144`) +calls the public overload and, in `onStarted`, reads `getSoftApConfiguration()` (API 30+) or +`getWifiConfiguration()` below it. That is the entire supported surface — hence the QR handoff. + +## Conventional tethering / SoftAP: also system-only + +The historical Android 4–6 approach set a `WifiConfiguration` and used reflection to call hidden +`setWifiApConfiguration` / `setWifiApEnabled`. Android 7 closed this: `WifiServiceImpl` now checks the +caller's UID/permission and throws `SecurityException` with "App not allowed to read or update stored +WiFi Ap config" for unprivileged callers, and the enable path requires `TETHER_PRIVILEGED`. + +| Permission | Protection level (API 24+) | Verdict | +|---|---|---| +| `CHANGE_WIFI_STATE` | normal (auto-granted) | Insufficient — controls the client, not SoftAP config | +| `WRITE_SETTINGS` | appop / signature | Insufficient — does not pass the AP-config check | +| `TETHER_PRIVILEGED` | signature / system | Required to start/stop/config tethering — OEM/root only | + +`WifiManager.setWifiApConfiguration` / `startTethering` are therefore not usable by a distributed APK. +"Not possible for a non-system app" — not "impossible" in the absolute sense (a platform-signed or +rooted build could). + +## Fallback for routed tethering: delegate to the Settings UI + +If routed tethering (clients reaching the internet via the phone's WAN) is a hard requirement, the only +user-space-respecting option is to send the user to the system UI with an `Intent`: + +- Universal: `Settings.ACTION_WIRELESS_SETTINGS`. +- Deep-link (fragile across OEMs): explicit component `com.android.settings.TetherSettings`, wrapped in + a try/catch that falls back to `ACTION_WIRELESS_SETTINGS` on `ActivityNotFoundException`. + +The user then sets or reads the SSID/password manually. This is a UX regression (multi-step, error-prone) +but it is the only framework-legal route to a *routed* hotspot without system privileges. + +## The viable self-managed path: Wi-Fi Direct Autonomous Group Owner (AGO) + +For a private, internet-isolated local network the app can host without system permissions, use Wi-Fi +Direct (`android.net.wifi.p2p.WifiP2pManager`). Calling `createGroup(channel, actionListener)` forces the +device to become the **Group Owner** immediately, skipping role negotiation — an Autonomous Group Owner. +An AGO behaves on the air exactly like a WPA2-PSK SoftAP: it beacons, authenticates clients, and routes +local frames. + +- On our SDK floor, `createGroup(channel, actionListener)` takes **no** configuration — the SSID + (`DIRECT-xy-...`, per the Wi-Fi Direct spec) and WPA2 passphrase are auto-generated by `wpa_supplicant`. +- The overload that lets you pre-set name/passphrase (`createGroup(channel, WifiP2pConfig, actionListener)` + built via `WifiP2pConfig.Builder.setNetworkName()/setPassphrase()`) was added in **API 29 (Android 10)**. + So pre-setting credentials is available only when running on API 29+. +- **Legacy client compatibility:** the Android docs state that legacy Wi-Fi clients join an AGO with the + network name + passphrase like any WPA2 AP — no P2P logic on the client side. This is the key property: + the host runs P2P internals; iOS, laptops, IoT devices just see a normal Wi-Fi network. + +This is a **proposal, not current code** — K2Go has no `WifiP2pManager` usage today (LOHS only). Adopting +it is new work. + +## Credential stabilization via persistent groups — with a caveat + +Wi-Fi Direct persistent groups serialize the group's credentials (SSID, passphrase, band) so a device can +re-form the same group later; on AOSP this lives in the on-device supplicant config. In practice, on many +devices, re-creating the group reuses the stored credentials, giving stable SSID/passphrase across sessions +and reboots until the user clears "remembered" P2P groups or does a network reset. + +Caveat (corrected from the source material): this stability is **empirical and OEM/version dependent**, not +a documented contract for the auto-generated AGO SSID. `createGroup()` without config does not guarantee the +same SSID on every device. **Before relying on "stable credentials," device-test the target hardware** (form +group, read creds, tear down, re-form, confirm identical) across the OEMs we ship to. + +## Reading the credentials back + +Credentials the host generated must reach clients (e.g. rendered as a QR, the pattern we already use): + +1. Register a `BroadcastReceiver` for `WIFI_P2P_CONNECTION_CHANGED_ACTION`. +2. On connection, call `WifiP2pManager.requestGroupInfo(channel, listener)`. +3. From the returned `WifiP2pGroup`: `getNetworkName()` → SSID, `getPassphrase()` → WPA2 key. + `getPassphrase()` returns non-null **only on the Group Owner** (clients get null — a deliberate + anti-leak). So the host app, being the GO, can read and display them. + +## Topology notes (why this fits a "local only" model) + +- **MAC randomization:** the P2P interface uses a randomized/virtual MAC; do not build security on static + MAC allow-lists. +- **Addressing:** the GO runs an embedded DHCP on the P2P interface with gateway **192.168.49.1** (clients get + 192.168.49.x). Note: LOHS on AOSP commonly uses the same 192.168.49.1 gateway, which is why the app's + hardcoded fallback works today (`ConnectFragment.java:201`, `CloneFragment.java:776`). +- **No WAN bridging:** a P2P GO does not NAT the phone's mobile data to clients — the network is internet-isolated. + That is a feature for a "local only" use case, not a bug. + +## Permissions matrix (Wi-Fi Direct path) + +| Permission | Level | Why | +|---|---|---| +| `ACCESS_WIFI_STATE` | normal | query radio state | +| `CHANGE_WIFI_STATE` | normal | form / remove P2P groups | +| `INTERNET` | normal | local socket stack | +| `ACCESS_FINE_LOCATION` | dangerous (runtime) | required for P2P discovery on API ≤ 32; without it P2P callbacks return empty/permission errors | +| `NEARBY_WIFI_DEVICES` | runtime | replaces the location requirement when **targeting API 33+** | + +The app currently declares `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE`, `ACCESS_FINE_LOCATION`, `INTERNET` +(`AndroidManifest.xml`) — enough for the LOHS path at `targetSdk 28`. It does **not** declare +`NEARBY_WIFI_DEVICES`; that becomes necessary if we adopt the P2P path and/or raise `targetSdk` to 33+. + +## Lifecycle + +The native daemon state is decoupled from the app's GC. A P2P host must call +`WifiP2pManager.removeGroup(channel, actionListener)` when the service ends or the app is backgrounded, +or the group keeps beaconing ("ghost" AP) and can wedge the radio. Confirm teardown via the action listener. + +## Recommendation + +- **Keep LOHS + QR handoff** as the default. Setting a fixed/predictable LOHS SSID/password is not achievable + for a regular app on stock Android — this note is the backing for that "no." +- **If a stable, host-controlled local AP becomes a requirement**, prototype the Wi-Fi Direct AGO path + (`createGroup` → `requestGroupInfo` → display creds), and — before committing — device-test the persistent-group + stability and legacy-client join across our target OEMs. On API 29+ we can also pre-set the credentials outright. +- A **managed/kiosk (Device Owner)** deployment is the only route to a system-controlled hotspot, and is a + different class of project. + +## Verified vs. to-verify + +- **Verified (primary sources + our code):** LOHS custom config is `@SystemApi` / system-permission-gated; + tethering config needs `TETHER_PRIVILEGED`; reflection blocked since Android 7; `createGroup` config overload + is API 29; `getPassphrase()` is owner-only; gateway 192.168.49.1; the app is LOHS-only and reads random creds. +- **To verify on device:** persistent-group SSID/passphrase stability across our target OEMs and Android + versions; legacy-client join behavior on the specific client devices we care about. + +## Sources + +- [Use a local-only Wi-Fi hotspot — Android Developers](https://developer.android.com/develop/connectivity/wifi/localonlyhotspot) +- [WifiManager.LocalOnlyHotspotCallback — Android Developers](https://developer.android.com/reference/android/net/wifi/WifiManager.LocalOnlyHotspotCallback) +- [WifiP2pManager — Android Developers](https://developer.android.com/reference/android/net/wifi/p2p/WifiP2pManager) +- [WifiP2pGroup — Android Developers](https://developer.android.com/reference/android/net/wifi/p2p/WifiP2pGroup) +- [WifiP2pConfig.Builder (API 29) — Android Developers](https://developer.android.com/reference/android/net/wifi/p2p/WifiP2pConfig.Builder) +- [Wi-Fi Direct (P2P) overview — Android Developers](https://developer.android.com/develop/connectivity/wifi/wifip2p) +- [Wi-Fi hotspot (Soft AP) — Android Open Source Project](https://source.android.com/docs/core/connect/wifi-softap) +- [Tethering — Android Open Source Project](https://source.android.com/docs/core/ota/modular-system/tethering) +- [WifiServiceImpl.java — AOSP (AP-config permission check)](https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/a8d5e40/service/java/com/android/server/wifi/WifiServiceImpl.java) +- [Configuring Android's LocalOnlyHotspot (SSID/BSSID) — Mike Dawson, Medium (secondary)](https://medium.com/@mike_21858/configuring-androids-localonlyhotspot-5ghz-defining-ssid-bssid-and-more-ef4e4975e7b4) From 112c2581a8fcd0a6c517ed1e944b11856c92a817 Mon Sep 17 00:00:00 2001 From: "Luis Guzman (AppDevForAll)" Date: Wed, 19 Aug 2026 17:02:27 -0600 Subject: [PATCH 2/2] ADFA-5197 docs(connectivity): device-to-device options, mechanisms and decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an engineering note that answers ADFA-5197 (preserve hotspot name/password across restarts) and maps the device-to-device connectivity options for K2Go. Finding: a fixed SSID/passphrase on a user-owned Android phone is not achievable with public APIs — the platform reserves AP naming for system components (LOHS/SoftAP/Wi-Fi Direct hand the app system-generated credentials it can read but not pin). The stable, printable experience the ticket wants is delivered by moving the AP off the phone (external router, fixed creds) and reaching the host by name via NSD/mDNS — a standard, unprivileged API — so a DHCP address that changes doesn't matter. LOHS + on-screen QR stays as the hardware-less fallback. Covers, grounded in the codebase: options A–E with the API mechanism folded in (LOHS, Wi-Fi Direct, Device Owner, Wi-Fi Aware, external AP); the `:8085` reachability cross-cut (bind is nginx-in-proot, not Android; interface discovery already done in `NetworkInterfaces`); an addressing-by-mode table; permissions; and a verified vs. to-verify checklist. Docs only — no code change. --- .../docs/device-to-device-connectivity.md | 278 ++++++++++++++++++ .../docs/hotspot-credentials-lohs-and-p2p.md | 197 ------------- 2 files changed, 278 insertions(+), 197 deletions(-) create mode 100644 controller/docs/device-to-device-connectivity.md delete mode 100644 controller/docs/hotspot-credentials-lohs-and-p2p.md diff --git a/controller/docs/device-to-device-connectivity.md b/controller/docs/device-to-device-connectivity.md new file mode 100644 index 00000000..331525c5 --- /dev/null +++ b/controller/docs/device-to-device-connectivity.md @@ -0,0 +1,278 @@ +# Device-to-device connectivity for KnowledgeToGo — options, mechanisms, and decision + +Engineering note. 2026-08-19. Ticket: ADFA-5197 — preserve hotspot name/password across +restarts. Context: ADFA-4520 (LocalOnlyHotspot for SIM-less devices). Status: draft for review. + +## TL;DR + +The goal is a join experience that stays stable across sessions and, ideally, a credential +someone can print once and post on a wall. This note maps how to get there. + +The clearest way to reach it is to **move the access point off the phone**: a low-cost router +holds a fixed SSID + passphrase, the K2Go phone joins it as a client and serves content on that +segment, and readers join normally. Print the QR once; it never changes across restarts or +updates + +The complement is to reach the phone **by name, not by IP** — e.g.: `k2go.local` the app advertises its service with +NSD/mDNS, a standard, unprivileged API, so a DHCP address that changes between sessions doesn't +matter. That is Option E, the recommendation wherever a little hardware can be deployed — and note +the inversion: the platform reserves naming the *hotspot*, but hands us naming the *service*. + +On the phone itself, Android reserves Wi-Fi AP naming to system components: LocalOnlyHotspot, +SoftAP and Wi-Fi Direct all hand the app a system-generated SSID + passphrase that a normal app +can read but not choose, and LocalOnlyHotspot regenerates them on every start. That is a design +boundary of the platform, so the sections below walk each on-phone path, show how far it gets, +and land on moving the AP off the device as the way the fixed-credential goal is actually met. +Where no hardware is available, today's LocalOnlyHotspot + on-screen QR keeps working as the +fallback — the credential rotates, but the join stays quick. + +## 1. Problem statement + +- The onboarding QR is different every session; nothing can be printed, laminated, or posted. +- Support/training material cannot reference a stable network name. +- In large venues, every host-app restart invalidates what clients already learned. + +Goal: a join experience stable across sessions, without per-device manual entry, ideally a +fixed printable credential. + +## 2. The hard constraint that shapes everything + +**Client and host devices belong to end users, not to the organization.** K2Go ships as an +app, not a managed fleet. Any option requiring factory reset, enterprise provisioning, or +administrative ownership of the handset is out of scope in practice — this alone eliminates +Option C below, otherwise the only sanctioned way to pin SoftAP credentials. + +## 3. How Android gates AP configuration (the platform boundary) + +Wi-Fi is layered: the app calls public `WifiManager`/`WifiP2pManager`, which IPC (Binder) into +`system_server`, where `WifiServiceImpl`/`ConnectivityService` enforce permissions by UID +before touching the native daemons (`wpa_supplicant` for client/P2P, `hostapd` for SoftAP). +Naming/securing an AP must pass that server-side check. Since Android 7 the AP-config path is +UID/permission-checked there (throwing `SecurityException` "App not allowed to read or update +stored WiFi Ap config"), which is why the Android 4–6 reflection tricks stopped working. +Enforcement is server-side, not evadable from the app process; API 28+ adds the hidden-API +greylist as a second block. + +## 4. Options at a glance + +| Option | Credential stability | User friction | OEM risk | Concurrency | Cost | Verdict | +|---|---|---|---|---|---|---| +| A — LOHS + on-screen QR | None (rotates) | Low, repeats each session | Medium | Low | Zero | **Fallback** | +| B — Wi-Fi Direct (P2P) | None (system-generated) | Low in theory | High | Poor | Zero | Rejected | +| C — Device Owner + `setSoftApConfiguration` | Full | Prohibitive (factory reset) | Low | Good | High (provisioning) | Rejected for our model | +| D — Wi-Fi Aware (NAN) | N/A (no SSID) | Very low | Irregular HW | Poor for fan-out | Zero | Not primary | +| E — External AP, fixed creds | **Full** | Lowest (printed QR) | Removed | **Best** | Hardware/site | **Recommended** | + +## 5. Options in detail (with the API mechanism folded in) + +### Option A — Status quo: LocalOnlyHotspot + on-screen QR +`startLocalOnlyHotspot(callback, handler)` returns a **random, read-only** SSID + WPA2 +passphrase. The custom-config overload (`startLocalOnlyHotspot(SoftApConfiguration, …)`) is +`@SystemApi`, gated behind `NETWORK_SETTINGS`/`NETWORK_SETUP_WIZARD`; `NEARBY_WIFI_DEVICES` +alone does not unlock it. So the app can read but never set or pin the credentials, and they +regenerate each start. + +Today: `LocalHotspotManager.start()` (`controller/.../hotspot/LocalHotspotManager.java:122-144`) +uses the public overload and reads `getSoftApConfiguration()` (API 30+) / `getWifiConfiguration()` +below — the whole supported surface, hence the QR handoff. + +Pros: works now; no hardware; no extra permissions; internet-free by design. +Cons: not printable; host screen must be visible/reachable (doesn't scale in a big room); +unstable for repeat visits. + +### Option B — Wi-Fi Direct (Wi-Fi P2P) +`createGroup()` makes the device an Autonomous Group Owner that behaves on-air like a WPA2 AP, +and legacy clients can join with name+passphrase (no P2P logic client-side). **But it does not +solve the stated problem**: on our SDK floor `createGroup` takes no config, so the SSID +(`DIRECT-xy-…`) and passphrase are still system-generated (the config overload arrived API 29). +Persistent groups *tend* to retain credentials across sessions, but this is empirical and +OEM/version dependent — not a contract. Behavior lives in vendor firmware/HAL (discovery +reliability, interaction with an active Wi-Fi connection, client caps), and one GO fanning out +to many readers over one radio degrades fast. + +Verdict: doesn't meet the goal, and reintroduces the OEM fragmentation Option E avoids. Not used +in the app today. + +### Option C — Device Owner + `setSoftApConfiguration()` +`setSoftApConfiguration()` **can** pin the SoftAP SSID/passphrase persistently, but it requires +`NETWORK_SETTINGS`/`NETWORK_SETUP_WIZARD` (signature). Clarification worth recording (it came up +as a misconception): **Device Owner ≠ handset manufacturer** — it's a device-management role any +app can hold, provisioned by QR/NFC/ADB, *but*: (1) only on a device with no accounts (out of box +or post-factory-reset); (2) one Device Owner per device, changing it needs another reset; (3) it +grants broad control of the handset. + +To verify (does not change the verdict): whether a plain Device Owner actually gets +`setSoftApConfiguration` on our target API levels, or whether it still needs `NETWORK_SETTINGS` +(a signature permission a DPC does not automatically hold). Wi-Fi controls exist via +`DevicePolicyManager` on fully-managed devices, but the exact SoftAP-pinning path is API-level +dependent and unconfirmed. + +Verdict: technically the cleanest fixed-credential path, viable **only** for organization-owned +devices. Asking end users to wipe their personal phone and hand K2Go admin control is not +realistic. Rejected for the current model; revisit if a managed-device pilot is funded. + +### Option D — Wi-Fi Aware (NAN) +`WifiAwareManager` (Android 8+) discovers peers and opens point-to-point **IPv6 data paths** with +no network to join and nothing to scan — conceptually elegant for "just connect me." But: it is a +per-device **hardware** capability (`PackageManager.FEATURE_WIFI_AWARE`), must be queried at +runtime with a mandatory fallback; it is a P2P data-path model, **not an AP with fan-out**, so one +host serving many readers is a poor fit; throughput and concurrent-session counts are limited and +unproven for our payloads. + +Verdict: not a primary path. Possibly an *opportunistic* discovery layer later, never the only one. + +### Option E — External access point with fixed credentials *(recommended)* +Take the phone's radio out of the serving role. A low-cost router provides the network with a fixed +SSID + passphrase; the K2Go host phone joins it **as a client** and serves content on that segment; +readers join normally. + +Pros: +- The QR is printed once and posted; never changes across sessions, reboots, or app updates. +- Coverage, antennas, and concurrent-client capacity all improve vs. a handset (consistent with the + earlier OnePlus 7T vs. Hikvision Wi-Fi 6 comparison). +- Eliminates the whole class of OEM SoftAP quirks (client isolation, subnet variance, client caps). +- The client app can use `WifiNetworkSuggestion` / `WifiNetworkSpecifier` (API 29+) so the join is + automatic once installed — fewer manual steps. (Not present in the app today; new work.) + +Cons: +- Requires hardware + power per site — a real constraint for K2Go's contexts. +- The host phone's LAN address is DHCP-assigned; needs a DHCP reservation on the AP **or** mDNS/NSD + service discovery so clients find the server without a hardcoded IP. +- Where no AP can be installed, it falls back to Option A, so **both paths must remain supported**. + +### 5.1 Stable host address without a static IP (the IP-independent counter-offer) + +Separating the AP from the phone raises one detail: the phone's address on that LAN is +DHCP-assigned and could change. The clean answer is **not to pin an IP at all** but to reach the +host by **name** — which turns the whole objection around, because naming a *service* is a +capability the platform hands to any app (unlike naming the *AP*, which it does not). + +- **Service discovery (NSD / mDNS) — recommended.** The host advertises its content service with + `NsdManager` (e.g. an `_http._tcp` instance named `k2go`); clients resolve it by name and connect + regardless of the current IP. It survives DHCP changes, reboots and reconnects with nothing to + reconfigure. + - **Android versions:** `NsdManager` has existed since **API 16 (Android 4.1)** — well below our + `minSdk 24`, so every supported device has it. Receiving multicast reliably on some devices needs + a `WifiManager.MulticastLock` held while discovering, under `CHANGE_WIFI_MULTICAST_STATE` — which + the app **already declares** (`AndroidManifest.xml`). + - **AP requirements:** none special. mDNS is link-local multicast (224.0.0.251:5353), peer-to-peer + on the LAN — the router runs nothing extra. It only needs the AP to **pass multicast and not have + client (AP) isolation** enabled. An AP that isolates clients or filters multicast would break mDNS + *and* direct client→host connections alike, so it is a setting to verify, not a new mechanism. + - **Standard, and not gated.** mDNS / DNS-SD is an industry standard (RFC 6762 / 6763; + Bonjour / Avahi / zeroconf), so iOS, laptops and IoT clients resolve the same name natively. + And unlike SoftAP naming, NSD is a **public, unprivileged app capability** — no system signature, + no special permission. Android's security model does not block it; the only thing that can + suppress it is a network that filters multicast, which we control on our own AP. It is the inverse + of the hotspot problem: the platform reserves AP naming for the system, but hands service naming + to any app. + +- **DHCP reservation — optional belt-and-suspenders.** If we control the router and also want a + predictable IP (logs, a printed URL), bind the phone's MAC to a fixed lease. Caveat: Android + randomizes the Wi-Fi MAC per SSID, but that MAC is **persistent while the phone remembers the + network** (default `RANDOMIZATION_PERSISTENT`), so the reservation holds; forgetting and re-adding + the network rotates the MAC and requires re-doing the reservation. Without any reservation, most + routers still hand the same device the same lease while the network is remembered — sticky in + practice, but not guaranteed. + +- **What the app cannot do:** set its own static IP. Pinning `IpConfiguration.STATIC` on a joined + network needs system / Device-Owner privileges — a third-party app cannot modify a network config + it did not create (the old `WifiConfiguration` + reflection route was closed on Android 6+). The + user can set it manually in Wi-Fi → Advanced → IP settings → Static, and the app can deep-link + there, but not apply it silently. + +## 6. Cross-cutting: reachability of `:8085` (separate from credentials) + +A prior report of clients not reaching the server port is unrelated to the credential problem and +is likely local to our stack: + +1. **Bind address — server-side, not Android.** Content is nginx inside the proot on `:8085` + (`config/BoxEndpoints.java` → `http://localhost:8085`; `:4000` Node behind it; tier-3 docs on + `:8114`). proot shares the host network namespace, so external reachability depends on nginx's + `listen` in the rootfs (must be `0.0.0.0:8085`, not `127.0.0.1`). Check the rootfs nginx config, + not the Android socket. +2. **Interface discovery — already done.** `NetworkInterfaces.discover()` enumerates up interfaces + at runtime (`wlan0`=Wi-Fi; `ap*`/`swlan*`/`wlan1`/`wlan2`=hotspot) and returns the IPv4; it does + not hardcode `192.168.43.x`. The only hardcode is a **fallback** `192.168.49.1` when the scan + returns null (`ConnectFragment.java:201`, `CloneFragment.java:776`). +3. **Client isolation.** Some OEMs isolate clients on LOHS; many venue APs enable AP isolation by + default. On a router we control (Option E) this is a verifiable, disableable setting. +4. **Full tethering vs. local-only.** Standard tethering routes client→host traffic normally; what + is usually blocked there is *upstream* internet, not traffic to the host. + +Action: confirm (1) in the rootfs nginx config before attributing any reachability failure to the +network mode; (2) is already implemented. + +## 7. Addressing by mode (one place, since it varies) + +| Mode | Host/gateway address | Notes | +|---|---|---| +| LocalOnlyHotspot | commonly `192.168.49.1` | AOSP convention; verify at runtime (done) | +| Wi-Fi Direct GO | `192.168.49.1` | fixed by the P2P spec's embedded DHCP | +| Full tethering (SoftAP) | commonly `192.168.43.1` | convention, **not** guaranteed — Samsung and others differ | +| External AP (Option E) | AP's DHCP range | needs DHCP reservation or mDNS/NSD for host discovery | + +Rule: never hardcode the served URL's IP; enumerate the active interface (already the app's +behavior) or resolve via NSD. + +## 8. Permissions + +| Permission | Level | Where it applies | +|---|---|---| +| `ACCESS_WIFI_STATE` / `CHANGE_WIFI_STATE` | normal | LOHS, P2P, joining an AP | +| `INTERNET` | normal | local socket stack | +| `ACCESS_FINE_LOCATION` | dangerous (runtime) | LOHS/P2P discovery on API ≤ 32 | +| `NEARBY_WIFI_DEVICES` | runtime | replaces location when **targeting API 33+** | +| `NETWORK_SETTINGS` / `NETWORK_SETUP_WIZARD` | signature/system | required to pin SoftAP/LOHS creds — not grantable to a normal app | + +The app declares `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE`, `ACCESS_FINE_LOCATION`, `INTERNET` +(`AndroidManifest.xml`) — enough at `targetSdk 28`. It does **not** declare `NEARBY_WIFI_DEVICES` +(needed if we adopt the P2P path or raise `targetSdk` to 33+). + +## 9. Recommendation + +1. **Primary — Option E** (external AP + fixed creds + printed QR) for any site where hardware can + be deployed. The only option delivering a stable, printable credential under our model. Add + `WifiNetworkSuggestion` auto-join + NSD/mDNS host discovery on the client side (new work). +2. **Fallback — Option A** (LOHS + on-screen QR) for ad-hoc / hardware-less sites. Accept rotation; + optimize the scan. +3. **Rejected:** B (doesn't solve it, adds vendor risk), C (incompatible with user-owned devices), + D (hardware too irregular to depend on). + +For ADFA-5197: the stable, printable credential the ticket asks for is delivered by moving the AP +off the phone (Option E). On a user-owned phone the platform keeps AP naming with system +components, so where hardware isn't available we still meet the underlying need — a quick, reliable +join — through the on-screen QR, and put our effort into that flow rather than into a fixed name the +platform reserves for system components. + +## 10. Verified vs. to-verify + +Verified (primary sources + our code): +- LOHS custom config is `@SystemApi` / system-permission-gated; tethering config needs + `TETHER_PRIVILEGED`; reflection blocked since Android 7; P2P `createGroup` config overload is API + 29; `WifiP2pGroup.getPassphrase()` is owner-only. +- The app is LOHS-only, reads random creds, and already does runtime interface discovery + (`NetworkInterfaces`); content server is nginx-in-proot on `:8085`; no `WifiNetworkSuggestion`/NSD. + +To verify before sign-off: +- [ ] Confirm nginx `listen 0.0.0.0:8085` in the rootfs (bind-address check — server-side, not Android). +- [ ] Measure real concurrent-client degradation on our reference handsets (don't cite secondhand). +- [ ] Confirm exact `setSoftApConfiguration()` privilege per API level vs. AOSP, for any managed-device pilot. +- [ ] Evaluate `WifiNetworkSuggestion` auto-join UX on Android 10–16 for the Option E client flow. +- [ ] Device-test P2P persistent-group credential stability across target OEMs (only if B is ever reconsidered). +- [ ] Spec the reference AP: cost, power, mounting, provisioning per site. +- [ ] Confirm NSD/mDNS host discovery on our reference APs (multicast passed, no client isolation); + keep a DHCP-reservation / printed-IP fallback for APs that filter multicast. + +## Sources + +- [Use a local-only Wi-Fi hotspot — Android Developers](https://developer.android.com/develop/connectivity/wifi/localonlyhotspot) +- [Wi-Fi Direct (P2P) overview — Android Developers](https://developer.android.com/develop/connectivity/wifi/wifip2p) +- [WifiP2pGroup — Android Developers](https://developer.android.com/reference/android/net/wifi/p2p/WifiP2pGroup) +- [WifiP2pConfig.Builder (API 29) — Android Developers](https://developer.android.com/reference/android/net/wifi/p2p/WifiP2pConfig.Builder) +- [Wi-Fi Aware — Android Developers](https://developer.android.com/develop/connectivity/wifi/wifi-aware) +- [WifiNetworkSuggestion — Android Developers](https://developer.android.com/reference/android/net/wifi/WifiNetworkSuggestion) +- [Use network service discovery (NsdManager) — Android Developers](https://developer.android.com/develop/connectivity/wifi/use-nsd) +- [Networking and telephony — Android Enterprise (DPC)](https://developer.android.com/work/dpc/network-telephony) +- [Wi-Fi hotspot (Soft AP) — Android Open Source Project](https://source.android.com/docs/core/connect/wifi-softap) +- [Tethering — Android Open Source Project](https://source.android.com/docs/core/ota/modular-system/tethering) diff --git a/controller/docs/hotspot-credentials-lohs-and-p2p.md b/controller/docs/hotspot-credentials-lohs-and-p2p.md deleted file mode 100644 index 5e87be69..00000000 --- a/controller/docs/hotspot-credentials-lohs-and-p2p.md +++ /dev/null @@ -1,197 +0,0 @@ -# Controlling hotspot credentials on Android: what a non-system app can and cannot do - -Engineering research note. 2026-08-19. Ticket: ADFA-5197 — preserve hotspot name/password -across restarts. Context: the ADFA-4520 LOHS family. - -## TL;DR - -A regular app (no root, not platform-signed, no privileged install) **cannot set the SSID -or password** of the hotspot it raises — neither the Local-Only Hotspot (LOHS) nor the -conventional tethering / SoftAP. Those knobs are gated behind system-signature permissions. -K2Go already lives with this: `LocalHotspotManager` starts a LOHS and only **reads** the -system-generated SSID/passphrase, then hands them off by QR. - -If the goal is a **stable, self-managed local Wi-Fi that the host app controls** (predictable -enough that clients can reconnect), the only path that stays inside the public SDK and the -permission model is **Wi-Fi Direct running as an Autonomous Group Owner** (`WifiP2pManager.createGroup()`). -On our SDK floor it still cannot *pre-set* the SSID/passphrase (that overload arrived in API 29), -but the credentials it generates can be read back and, via persistent groups, tend to remain -stable across sessions — with the caveat that this stability is empirical, not contractually -guaranteed, and must be device-verified before we rely on it. - -This note is the backing for saying "no, we can't rename/secure the LOHS ourselves," and for -scoping the Wi-Fi Direct alternative if a stable local AP becomes a requirement. - -## Scope and the app's real SDK range - -The relevant constraint is: no root, no platform signature, no Google Play Services dependency. -The app is `minSdk 24 / targetSdk 28 / compileSdk 34` (`controller/app/build.gradle`). LOHS is an -API-26 feature, so `LocalHotspotManager.isSupported()` gates on `Build.VERSION.SDK_INT >= O`; on a -true Android 7.0 (API 24) device LOHS does not exist at all. So statements below are qualified by -API level rather than framed as "API 24 only". - -## How Android gates network configuration - -Wi-Fi is layered: the app calls the public `WifiManager` / `WifiP2pManager` in the app process, -which IPC (Binder) into `system_server`, where `WifiServiceImpl` / `ConnectivityService` enforce -permissions by UID before talking to the native daemons (`wpa_supplicant` for client/P2P, `hostapd` -for SoftAP). Any attempt to name or secure an AP must pass that server-side check. Since Android 7, -the AP-config path is explicitly UID/permission-checked there — which is why reflection tricks that -worked on Android 4–6 stopped working (details below). Enforcement is server-side in `system_server`, -not something the caller can evade from the app process; on API 28+ the hidden-API greylist adds a -second block to the reflected symbols. - -## Local-Only Hotspot (LOHS): read-only credentials - -The public entry point is `WifiManager.startLocalOnlyHotspot(callback, handler)`. The system picks a -random SSID and a high-entropy WPA2 passphrase and hands them to the app through the reservation. The -app can read them but not choose them: - -| Capability | Permission required | Feasible without root / platform signature | -|---|---|---| -| Read the generated SSID / passphrase | none (public callback) | Yes — this is what the app does | -| Set a custom SSID | `NETWORK_SETTINGS` | No — signature-level, OEM/platform only | -| Set a custom passphrase / BSSID | `NETWORK_SETUP_WIZARD` | No — reserved for setup/provisioning apps | -| Pin / reuse the same credentials next time | n/a | No — regenerated per reservation | - -A custom-config overload exists (`startLocalOnlyHotspot(SoftApConfiguration, executor, callback)`) but -it is `@SystemApi` (absent from the public SDK) and still requires `NETWORK_SETTINGS` / -`NETWORK_SETUP_WIZARD`; `NEARBY_WIFI_DEVICES` alone does not unlock the custom-config path for a normal -app. The official developer guide only documents the no-config overload. Net: for K2Go, LOHS SSID and -password are **read-only and non-stable** (they change each time the hotspot comes up). - -How we use it today: `LocalHotspotManager.start()` (`controller/.../hotspot/LocalHotspotManager.java:122-144`) -calls the public overload and, in `onStarted`, reads `getSoftApConfiguration()` (API 30+) or -`getWifiConfiguration()` below it. That is the entire supported surface — hence the QR handoff. - -## Conventional tethering / SoftAP: also system-only - -The historical Android 4–6 approach set a `WifiConfiguration` and used reflection to call hidden -`setWifiApConfiguration` / `setWifiApEnabled`. Android 7 closed this: `WifiServiceImpl` now checks the -caller's UID/permission and throws `SecurityException` with "App not allowed to read or update stored -WiFi Ap config" for unprivileged callers, and the enable path requires `TETHER_PRIVILEGED`. - -| Permission | Protection level (API 24+) | Verdict | -|---|---|---| -| `CHANGE_WIFI_STATE` | normal (auto-granted) | Insufficient — controls the client, not SoftAP config | -| `WRITE_SETTINGS` | appop / signature | Insufficient — does not pass the AP-config check | -| `TETHER_PRIVILEGED` | signature / system | Required to start/stop/config tethering — OEM/root only | - -`WifiManager.setWifiApConfiguration` / `startTethering` are therefore not usable by a distributed APK. -"Not possible for a non-system app" — not "impossible" in the absolute sense (a platform-signed or -rooted build could). - -## Fallback for routed tethering: delegate to the Settings UI - -If routed tethering (clients reaching the internet via the phone's WAN) is a hard requirement, the only -user-space-respecting option is to send the user to the system UI with an `Intent`: - -- Universal: `Settings.ACTION_WIRELESS_SETTINGS`. -- Deep-link (fragile across OEMs): explicit component `com.android.settings.TetherSettings`, wrapped in - a try/catch that falls back to `ACTION_WIRELESS_SETTINGS` on `ActivityNotFoundException`. - -The user then sets or reads the SSID/password manually. This is a UX regression (multi-step, error-prone) -but it is the only framework-legal route to a *routed* hotspot without system privileges. - -## The viable self-managed path: Wi-Fi Direct Autonomous Group Owner (AGO) - -For a private, internet-isolated local network the app can host without system permissions, use Wi-Fi -Direct (`android.net.wifi.p2p.WifiP2pManager`). Calling `createGroup(channel, actionListener)` forces the -device to become the **Group Owner** immediately, skipping role negotiation — an Autonomous Group Owner. -An AGO behaves on the air exactly like a WPA2-PSK SoftAP: it beacons, authenticates clients, and routes -local frames. - -- On our SDK floor, `createGroup(channel, actionListener)` takes **no** configuration — the SSID - (`DIRECT-xy-...`, per the Wi-Fi Direct spec) and WPA2 passphrase are auto-generated by `wpa_supplicant`. -- The overload that lets you pre-set name/passphrase (`createGroup(channel, WifiP2pConfig, actionListener)` - built via `WifiP2pConfig.Builder.setNetworkName()/setPassphrase()`) was added in **API 29 (Android 10)**. - So pre-setting credentials is available only when running on API 29+. -- **Legacy client compatibility:** the Android docs state that legacy Wi-Fi clients join an AGO with the - network name + passphrase like any WPA2 AP — no P2P logic on the client side. This is the key property: - the host runs P2P internals; iOS, laptops, IoT devices just see a normal Wi-Fi network. - -This is a **proposal, not current code** — K2Go has no `WifiP2pManager` usage today (LOHS only). Adopting -it is new work. - -## Credential stabilization via persistent groups — with a caveat - -Wi-Fi Direct persistent groups serialize the group's credentials (SSID, passphrase, band) so a device can -re-form the same group later; on AOSP this lives in the on-device supplicant config. In practice, on many -devices, re-creating the group reuses the stored credentials, giving stable SSID/passphrase across sessions -and reboots until the user clears "remembered" P2P groups or does a network reset. - -Caveat (corrected from the source material): this stability is **empirical and OEM/version dependent**, not -a documented contract for the auto-generated AGO SSID. `createGroup()` without config does not guarantee the -same SSID on every device. **Before relying on "stable credentials," device-test the target hardware** (form -group, read creds, tear down, re-form, confirm identical) across the OEMs we ship to. - -## Reading the credentials back - -Credentials the host generated must reach clients (e.g. rendered as a QR, the pattern we already use): - -1. Register a `BroadcastReceiver` for `WIFI_P2P_CONNECTION_CHANGED_ACTION`. -2. On connection, call `WifiP2pManager.requestGroupInfo(channel, listener)`. -3. From the returned `WifiP2pGroup`: `getNetworkName()` → SSID, `getPassphrase()` → WPA2 key. - `getPassphrase()` returns non-null **only on the Group Owner** (clients get null — a deliberate - anti-leak). So the host app, being the GO, can read and display them. - -## Topology notes (why this fits a "local only" model) - -- **MAC randomization:** the P2P interface uses a randomized/virtual MAC; do not build security on static - MAC allow-lists. -- **Addressing:** the GO runs an embedded DHCP on the P2P interface with gateway **192.168.49.1** (clients get - 192.168.49.x). Note: LOHS on AOSP commonly uses the same 192.168.49.1 gateway, which is why the app's - hardcoded fallback works today (`ConnectFragment.java:201`, `CloneFragment.java:776`). -- **No WAN bridging:** a P2P GO does not NAT the phone's mobile data to clients — the network is internet-isolated. - That is a feature for a "local only" use case, not a bug. - -## Permissions matrix (Wi-Fi Direct path) - -| Permission | Level | Why | -|---|---|---| -| `ACCESS_WIFI_STATE` | normal | query radio state | -| `CHANGE_WIFI_STATE` | normal | form / remove P2P groups | -| `INTERNET` | normal | local socket stack | -| `ACCESS_FINE_LOCATION` | dangerous (runtime) | required for P2P discovery on API ≤ 32; without it P2P callbacks return empty/permission errors | -| `NEARBY_WIFI_DEVICES` | runtime | replaces the location requirement when **targeting API 33+** | - -The app currently declares `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE`, `ACCESS_FINE_LOCATION`, `INTERNET` -(`AndroidManifest.xml`) — enough for the LOHS path at `targetSdk 28`. It does **not** declare -`NEARBY_WIFI_DEVICES`; that becomes necessary if we adopt the P2P path and/or raise `targetSdk` to 33+. - -## Lifecycle - -The native daemon state is decoupled from the app's GC. A P2P host must call -`WifiP2pManager.removeGroup(channel, actionListener)` when the service ends or the app is backgrounded, -or the group keeps beaconing ("ghost" AP) and can wedge the radio. Confirm teardown via the action listener. - -## Recommendation - -- **Keep LOHS + QR handoff** as the default. Setting a fixed/predictable LOHS SSID/password is not achievable - for a regular app on stock Android — this note is the backing for that "no." -- **If a stable, host-controlled local AP becomes a requirement**, prototype the Wi-Fi Direct AGO path - (`createGroup` → `requestGroupInfo` → display creds), and — before committing — device-test the persistent-group - stability and legacy-client join across our target OEMs. On API 29+ we can also pre-set the credentials outright. -- A **managed/kiosk (Device Owner)** deployment is the only route to a system-controlled hotspot, and is a - different class of project. - -## Verified vs. to-verify - -- **Verified (primary sources + our code):** LOHS custom config is `@SystemApi` / system-permission-gated; - tethering config needs `TETHER_PRIVILEGED`; reflection blocked since Android 7; `createGroup` config overload - is API 29; `getPassphrase()` is owner-only; gateway 192.168.49.1; the app is LOHS-only and reads random creds. -- **To verify on device:** persistent-group SSID/passphrase stability across our target OEMs and Android - versions; legacy-client join behavior on the specific client devices we care about. - -## Sources - -- [Use a local-only Wi-Fi hotspot — Android Developers](https://developer.android.com/develop/connectivity/wifi/localonlyhotspot) -- [WifiManager.LocalOnlyHotspotCallback — Android Developers](https://developer.android.com/reference/android/net/wifi/WifiManager.LocalOnlyHotspotCallback) -- [WifiP2pManager — Android Developers](https://developer.android.com/reference/android/net/wifi/p2p/WifiP2pManager) -- [WifiP2pGroup — Android Developers](https://developer.android.com/reference/android/net/wifi/p2p/WifiP2pGroup) -- [WifiP2pConfig.Builder (API 29) — Android Developers](https://developer.android.com/reference/android/net/wifi/p2p/WifiP2pConfig.Builder) -- [Wi-Fi Direct (P2P) overview — Android Developers](https://developer.android.com/develop/connectivity/wifi/wifip2p) -- [Wi-Fi hotspot (Soft AP) — Android Open Source Project](https://source.android.com/docs/core/connect/wifi-softap) -- [Tethering — Android Open Source Project](https://source.android.com/docs/core/ota/modular-system/tethering) -- [WifiServiceImpl.java — AOSP (AP-config permission check)](https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/a8d5e40/service/java/com/android/server/wifi/WifiServiceImpl.java) -- [Configuring Android's LocalOnlyHotspot (SSID/BSSID) — Mike Dawson, Medium (secondary)](https://medium.com/@mike_21858/configuring-androids-localonlyhotspot-5ghz-defining-ssid-bssid-and-more-ef4e4975e7b4)