Android: one state flow, the relay socket, and credentials
How the Android app runs on one ViewModel and a pure reducer without a DI framework, how its OkHttp socket registers, sorts frames, and follows the Activity lifecycle, and how Keystore credentials and sign-out work.
Checked against the source on 17 September 2026
On this page
One module and a hand-built graph
- Saved Compose staterememberSaveable
AppRoute, the workspace entry tab, and the region ride in the Activity’s saved instance state, so the app reopens on the same route. The app uses no SavedStateHandle.
- Keystore storesAndroidKeystoreStringCipher
The last view per desktop, the session order, and the project directories are read into MobileScreenState when the ViewModel is created. The app JWT and the outbox are encrypted the same way.
- Cached desktop listDesktopDirectoryStore
Plain JSON scoped to the account and the server. The last view comes back only if its desktop is still listed and no notification target is waiting. Every listed desktop starts offline.
- Desktop linkDesktopLinkRecoveryState
Never read from disk. It turns ready only when the selected desktop answers system.ping, and until then new sends wait in encrypted custody.
The Android app is one :app module in Kotlin and Jetpack Compose with minSdk 26 and compile and target SDK 36. It has no dependency injection framework and no Application subclass. MainActivityDependencies builds the repositories and the relay client for the selected region from resources, the stored region, and the stored token, and hands out do-nothing implementations such as NoDesktopRpcClient when configuration or authentication is missing. JSON goes through the platform’s org.json, and Room, DataStore, WorkManager, and kotlinx.serialization are not part of the build.
One PlanToCodeAndroidViewModel owns a MutableStateFlow<MobileScreenState>. Coordinators for sessions, data, the outbox, reconciliation, the account, desktop recovery, command output, approvals, the live timeline, and timeline interest run their coroutines and report results as actions through a single dispatch function. MobileScreenReducer.reduce is pure, and it prunes the timeline projection cache after every action. The ViewModel’s key contains the region ID, so choosing another region creates a new ViewModel with a new graph, and no request can reach the previous backend.
Navigation is two enums and no navigation library. AppRoute covers region, sign-in, onboarding, the access check, the paywall, device selection, missing configuration, and the workspace. MobileTab holds Project, Directories, Workspace, Changes, and Settings, and visibleMobileTabsFor shows Directories and Changes once a project is selected and Workspace once a session is selected as well. There is no SavedStateHandle. After process death the app rebuilds its state from Keystore-encrypted stores for the last view per desktop, the session order, and the project directories, and the cached desktop list supplies identity, never reachability.
An OkHttp WebSocket with generation fencing
The relay URL is the region’s https base URL with the scheme changed to wss and the path /ws/device-link, and the configuration refuses anything but wss. The OkHttp client pings every 20 seconds with no read timeout, and that ping is the only heartbeat. The upgrade carries the app JWT as a bearer token, X-Device-ID and X-Token-Binding with the device ID, X-Client-Type: mobile, and X-Target-Desktop-Device-ID.
The client sends register with relayProtocolVersion 1.6 and expects a registered, resumed, or session frame within 15 seconds. Resume credentials live in memory only. An invalidResume error closes that socket and connects once more without them, inside the same connect call. A ConnectionGenerationGate numbers every socket, and frame handling, the registration of pending calls, and control writes all run under the lock that swaps generations, so nothing from an old socket reaches the current one.
Automatic reconnect starts only after the client registered at least once and was not closed on purpose. It waits 1, 2, 4, 8, and then 15 seconds between attempts within a 92-second window. A 401 on the upgrade refreshes the token before the retry, and a 403 stops retrying.
Every RpcRequest mints its own W3C traceparent. A response frame without a valid traceparent is dropped, and a response whose trace ID differs from the request’s fails with -32602 and is not retried. Requests time out after 60 seconds, or 115 seconds for run.codexChatLoad and run.codexChatLoadThread, and a timeout counts as retry with backoff. Constructing an RpcRequest for one of the 22 mutations without an idempotency key throws. JVM tests read the iOS method catalog and the desktop’s Rust dispatch code from the repository, compare them with the 65 RpcMethod names, and forbid terminal.* and plan.*.
Frames are sorted before any JSON is parsed
- Control framesrpc.response · error · registered · resumed · session
A scan of the top-level type sorts every frame, and these are decoded at once on the OkHttp thread, so they never wait behind events. A response completes the pending call with its request ID and fails with -32602 when its trace ID differs. An error frame other than invalidResume fails the connection and every pending call.
- Event decoderDispatchers.Default
Parses event frames off the socket thread and holds at most 64 frames, 32 MiB each and 64 MiB in total. A frame that doesn’t fit fails the connection, which then reconnects.
- Held snapshotschat:timeline-updated · blocks
One slot per source desktop, session, run, Codex thread, and agent thread, at most 64, oldest dropped first. A transition such as completed moves its slot’s snapshot into the critical queue just ahead of itself.
- Critical eventsdevice-status · started · completed
Every other event queues in order, up to 128, and is delivered ahead of held snapshots. A full queue fails the connection. Events from an older connection or for another desktop never reach the ViewModel.
OkHttp delivers text frames on its own thread. A hand-written scanner reads only the top-level type key and sorts the frame into rpc.response, error, registered, resumed, session, or event without building a JSON object. Responses, errors, and session frames are decoded at once. Events go to a decoder on Dispatchers.Default with room for 64 frames, 32 MiB per frame, and 64 MiB queued, and a frame that does not fit fails the connection, which then reconnects.
Decoded events pass through RelayEventIngress for the current connection generation. chat:timeline-updated snapshots with event blocks merge per source device, app session, run, Codex thread, and agent thread, keep only the newest, hold at most 64 keys by dropping the oldest, and reach the ViewModel at most once every 200 milliseconds. Every other event is critical: it queues in order, up to 128, and goes first. A timeline transition for a key first moves that key’s pending snapshot into the ordered queue, so a snapshot never arrives after the transition that followed it. An overflowing critical queue fails the connection.
The Activity lifecycle owns the connection
- LeavingonStop
Timeline interest is withdrawn at once, then the per-desktop client closes with its resume credentials. While media plays, the socket stays open, but without timeline interest.
- Coming backonStart · 1.5 s
Recovery starts after 1.5 s, or at once when a desktop_online push hint names the selected desktop, and only while the app is visible with a network. It opens a new socket and sends system.ping with a 3 s timeout.
- Probes4 s · 16 s · 90 s
Without an answer, probes follow 4 s after recovery starts and then every 16 s, until the relay’s reconnect deadline or 90 s, at most 100 times. Then the desktop shows as offline. An answer cancels them.
- Interest leasechat:timeline-interest
Sent only on a registered socket, for the Workspace session on screen, and renewed every 20 s. The relay keeps it for 60 s and drops it when the socket closes.
MainActivity.onStop suspends the desktop connection unless a media stream is playing, and suspending discards the per-desktop client together with its resume credentials. onStart schedules recovery after 1.5 seconds, or at once when a desktop_online hint from a push is waiting. Recovery runs only while the Activity is visible, the network is available, and the hint names the selected desktop.
Recovery probes the desktop 4 seconds after it starts and then every 16 seconds, until the server’s reconnect deadline or a 90-second fallback, with at most 100 attempts, and then shows the desktop as offline. A device-status presence event is ignored when it comes from a relay instance other than the one the client is bound to.
Timeline interest follows the Activity’s visibility. WorkspaceTimelineSyncGate is the one place that authorizes content-bearing timeline work, and its scope exists only while the Activity is visible on the Workspace destination with a session, project, and desktop selected. The interest coordinator sends chat:timeline-interest with the base session ID on the current registered socket only, never opens a connection for it, renews it every 20 seconds, and withdraws it when the scope changes. A socket kept open for media playback after onStop therefore carries no timeline interest.
Desktops other than the selected one get short-lived clients that may send only system.ping, session.listRecent, and run.list, which is enough to show whether they respond and what they worked on recently.
Keystore, sign-in, and sign-out
Secrets are encrypted with AES-GCM keys held in the Android Keystore, with randomized IVs and 128-bit tags, and the ciphertext is stored in SharedPreferences. Separate key aliases protect the app JWT, the device identity, the outbox, the workspace view state, the project session order, and the project directory selection. Backups are off, and the backup and data extraction rules exclude every domain. Keystore keys never leave the device, so a restored copy of the ciphertext would be unreadable anyway.
Sign-in polls instead of redirecting. The app creates a polling ID, a CSRF token, and an S256 PKCE challenge, opens /auth/auth0/initiate-login in a Custom Tab, and polls /auth0/poll-status every 2 seconds, up to 60 times. It exchanges the code and verifier with Auth0 itself and receives the app JWT from /auth0/finalize-login, so no App Link is involved. Android does not restore sign-in progress as saved UI state after process loss; a new attempt replaces the previous one. The token refreshes through /api/auth0/refresh-app-token once it is within 5 minutes of expiry, and a session generation counter keeps a refresh or login that finishes late from writing over a logout.
- Server callsDELETE /api/devices/{id} · POST /api/auth/logout
Both are best effort, and a failure of either is ignored. The logout call removes the stored app JWT in a finally block, whether or not the server answered.
- Session generationsessionGeneration
Logout and every new sign-in attempt increment it. A refresh or sign-in that started earlier fails its generation check and saves nothing.
- Outbox fenceAccountBoundOutboxScope
New outbox work starts on an already-cancelled job, and cleanup waits in a non-cancellable context until the retired jobs finish. Without a token the outbox has no owner, so its writes fail anyway.
- Local cleanupclearAll() · commit()
The encrypted outbox must be removed first, then the session order, the project directories, and the view state. Afterwards the cached desktop list and the push token sync state are cleared, and sign-in opens.