Skip to article
PlanToCodeDocsGet the app

HandbookAndroid app

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

After process death the screen comes back from disk, but the desktop has to answer again
After process death the screen comes back from disk, but the desktop has to answer againAndroid kills the app process in the background, and everything in memory is lost, the desktop link included. The route comes back from saved Compose state. When the new ViewModel is created, the session order and the project directories come back from Keystore-encrypted stores, and the last view comes back too if its desktop is still in the cached desktop list. No store holds the desktop link, so it stays not ready until the selected desktop answers system.ping.
Desktop linkmemory only
ViewModelMobileScreenState
Cached desktop listnames only
Keystore storesencrypted
Saved Compose stateinstance state
process killed in the background
ready
not ready
ready
desktop answers system.ping
screen state in memory
rebuilt when the ViewModel is created
desktop names, every one offline
last view, session order, directories
route, entry tab, region
same route restored
session order, directories
last view
only if its desktop is listed, else opens on Project
  • 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

Critical events go first, and each timeline delivers only its newest snapshot
Critical events go first, and each timeline delivers only its newest snapshotAn example burst of six frames, drawn to scale. The rpc.response is answered on the socket thread at once. device-status goes straight to the ViewModel. The newer parent snapshot replaces the older one, and when parent completed arrives it releases the held parent snapshot just before itself. The thread snapshot waits until 200 ms pass without a critical event, because every critical event restarts that timer.
example burst, drawn to scale
Socket framesOkHttp thread
Held snapshotsnewest per timeline
Snapshot timerrestarts on critical events
ViewModelrecovery · live timeline
thread
parent, older
parent, newer
200 ms without a critical event
rpc.response, answered at once
device-status
parent completed
device-status
newer parent, then completed
thread snapshot
0 ms
100 ms
200 ms
300 ms
400 ms
  • 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

The phone drops its desktop link when you leave and proves it again when you return
The phone drops its desktop link when you leave and proves it again when you returnAt onStop the phone withdraws its timeline interest and closes the relay socket, and the resume credentials go with it. 1.5 s after onStart, recovery opens a new socket. As soon as it registers, the timeline interest is sent again and renewed every 20 s. The link turns ready only when the desktop answers system.ping within 3 s, and then the visible timeline and the waiting sends are reconciled. Without an answer, probes follow, and in the end the desktop shows as offline.
in the background
Relay socket
Timeline interest
Desktop link
registered
closed, resume credentials dropped
new socket
registered
renewed every 20 s
withdrawn
sent again, 60 s lease renewed every 20 s
ready
not ready
desktop answers system.ping
ready
timeline and waiting sends reconciled
no answer: probes until the deadline, then offline
onStop
onStart
+1.5 s
  • 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.

Sign-out doesn’t wait for the server, and work still running can’t bring the token or the outbox back
Sign-out doesn’t wait for the server, and work still running can’t bring the token or the outbox backSign-out first refuses new outbox work and cancels the running jobs. Then it calls the server on a best-effort basis, and here both calls fail. The app JWT is removed either way and the session generation goes up, so a token refresh that started earlier saves nothing. Local cleanup waits for the cancelled jobs, removes the encrypted outbox with commit(), then the session order, directories, and view state, and only then opens sign-in.
Regional server
Account exit
Token refresh
Outbox work
Keystore stores
unregister device
server logout
unreachable, sign-out continues
sign out
best-effort server calls
app JWT removed, generation + 1
wait for jobs
clear local stores
sign-in opens
refresh started earlier
generation changed, nothing saved
new work refused
cancelled jobs finish
app JWT
encrypted outbox, session order, directories, view state
If the outbox can’t be removed, sign-out stops with an error. The token is already gone, and signing out again resumes at local cleanup.
  • 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.