Android: ein State-Flow, der Relay-Socket und Anmeldedaten
Wie die Android-App ohne DI-Framework auf einem ViewModel und einem reinen Reducer läuft, wie ihr OkHttp-Socket sich registriert, Frames sortiert und dem Activity-Lebenszyklus folgt, und wie Keystore-Anmeldedaten und die Abmeldung funktionieren.
Gegen den Quellcode geprüft am 17. September 2026
Auf dieser Seite
Ein Modul und ein handgebauter Graph
- Compose-ZustandrememberSaveable
AppRoute, der Einstiegs-Tab des Workspace und die Region reisen im gesicherten Instanzzustand der Activity mit, deshalb öffnet die App wieder dieselbe Route. Die App verwendet kein SavedStateHandle.
- Keystore-SpeicherAndroidKeystoreStringCipher
Die letzte Ansicht pro Desktop, die Session-Reihenfolge und die Projektordner werden in MobileScreenState gelesen, wenn das ViewModel entsteht. Das App-JWT und die Outbox sind auf dieselbe Weise verschlüsselt.
- Desktop-ListeDesktopDirectoryStore
Reines JSON, auf das Konto und den Server beschränkt. Die letzte Ansicht kommt nur zurück, wenn ihr Desktop noch aufgeführt ist und kein Benachrichtigungsziel wartet. Jeder aufgeführte Desktop startet offline.
- Desktop-VerbindungDesktopLinkRecoveryState
Wird nie vom Speicher gelesen. Sie wird erst bereit, wenn der ausgewählte Desktop auf system.ping antwortet, und bis dahin warten neue Sendungen in verschlüsselter Verwahrung.
Die Android-App ist ein :app-Modul in Kotlin und Jetpack Compose mit minSdk 26 sowie Compile- und Target-SDK 36. Sie hat kein Dependency-Injection-Framework und keine Application-Unterklasse. MainActivityDependencies baut die Repositories und den Relay-Client für die gewählte Region aus Ressourcen, der gespeicherten Region und dem gespeicherten Token und gibt wirkungslose Implementierungen wie NoDesktopRpcClient aus, wenn Konfiguration oder Anmeldung fehlen. JSON läuft über das org.json der Plattform, und Room, DataStore, WorkManager und kotlinx.serialization gehören nicht zum Build.
Ein PlanToCodeAndroidViewModel besitzt einen MutableStateFlow<MobileScreenState>. Koordinatoren für Sessions, Daten, die Outbox, den Abgleich, das Konto, die Desktop-Wiederherstellung, Befehlsausgabe, Freigaben, die Live-Timeline und das Timeline-Interesse führen ihre Coroutinen aus und melden Ergebnisse als Aktionen über eine einzige dispatch-Funktion. MobileScreenReducer.reduce ist rein und bereinigt nach jeder Aktion den Cache der Timeline-Projektionen. Der Schlüssel des ViewModels enthält die Regions-ID, deshalb erzeugt die Wahl einer anderen Region ein neues ViewModel mit einem neuen Graphen, und keine Anfrage kann das vorherige Backend erreichen.
Die Navigation besteht aus zwei Enums und keiner Navigationsbibliothek. AppRoute umfasst Region, Anmeldung, Onboarding, die Zugriffsprüfung, die Paywall, die Geräteauswahl, fehlende Konfiguration und den Arbeitsbereich. MobileTab enthält Project, Directories, Workspace, Changes und Settings, und visibleMobileTabsFor zeigt Directories und Changes, sobald ein Projekt gewählt ist, und Workspace, sobald zusätzlich eine Session gewählt ist. Einen SavedStateHandle gibt es nicht. Nach einem Prozessende baut die App ihren Zustand aus Keystore-verschlüsselten Speichern für die letzte Ansicht pro Desktop, die Session-Reihenfolge und die Projektordner wieder auf, und die zwischengespeicherte Desktopliste liefert Identität, nie Erreichbarkeit.
Ein OkHttp-WebSocket mit Generationszaun
Die Relay-URL ist die https-Basis-URL der Region mit dem Schema wss und dem Pfad /ws/device-link, und die Konfiguration weist alles außer wss ab. Der OkHttp-Client sendet alle 20 Sekunden einen Ping ohne Lese-Zeitlimit, und dieser Ping ist der einzige Heartbeat. Das Upgrade trägt das App-JWT als Bearer-Token, X-Device-ID und X-Token-Binding mit der Geräte-ID, X-Client-Type: mobile und X-Target-Desktop-Device-ID.
Der Client sendet register mit relayProtocolVersion 1.6 und erwartet innerhalb von 15 Sekunden einen registered-, resumed- oder session-Frame. Resume-Anmeldedaten leben nur im Arbeitsspeicher. Ein invalidResume-Fehler schließt diesen Socket und verbindet innerhalb desselben connect-Aufrufs noch einmal ohne sie. Ein ConnectionGenerationGate nummeriert jeden Socket, und Frame-Verarbeitung, die Registrierung ausstehender Aufrufe und Steuer-Schreibvorgänge laufen alle unter der Sperre, die Generationen tauscht, sodass nichts von einem alten Socket den aktuellen erreicht.
Die automatische Wiederverbindung beginnt erst, nachdem sich der Client mindestens einmal registriert hat und nicht absichtlich geschlossen wurde. Sie wartet 1, 2, 4, 8 und dann 15 Sekunden zwischen den Versuchen, innerhalb eines Fensters von 92 Sekunden. Ein 401 beim Upgrade erneuert das Token vor dem nächsten Versuch, und ein 403 beendet die Versuche.
Jede RpcRequest erzeugt ihren eigenen W3C-traceparent. Ein Antwort-Frame ohne gültigen traceparent wird verworfen, und eine Antwort, deren Trace-ID von der der Anfrage abweicht, scheitert mit -32602 und wird nicht wiederholt. Anfragen laufen nach 60 Sekunden ab, oder nach 115 Sekunden für run.codexChatLoad und run.codexChatLoadThread, und ein Zeitlimit zählt als Wiederholung mit Backoff. Eine RpcRequest für eine der 22 Mutationen ohne Idempotenzschlüssel zu erzeugen wirft einen Fehler. JVM-Tests lesen den iOS-Methodenkatalog und den Rust-Dispatch-Code des Desktops aus dem Repository, vergleichen sie mit den 65 RpcMethod-Namen und verbieten terminal.* und plan.*.
Frames werden sortiert, bevor JSON geparst wird
- Steuer-Framesrpc.response · error · registered · resumed · session
Ein Scan des obersten type-Felds sortiert jeden Frame. Diese Frames werden sofort auf dem OkHttp-Thread dekodiert und warten nie hinter Events. Eine Antwort schließt den ausstehenden Aufruf mit ihrer Request-ID ab und scheitert mit -32602, wenn ihre Trace-ID abweicht. Ein error-Frame außer invalidResume lässt die Verbindung und jeden ausstehenden Aufruf scheitern.
- Event-DecoderDispatchers.Default
Parst Event-Frames abseits des Socket-Threads und hält höchstens 64 Frames, je 32 MiB und insgesamt 64 MiB. Ein Frame, der nicht passt, lässt die Verbindung scheitern, die sich dann neu verbindet.
- Gehaltene Snapshotschat:timeline-updated · blocks
Ein Platz pro Quell-Desktop, Session, Run, Codex-Thread und Agent-Thread, höchstens 64, der älteste fällt zuerst weg. Ein Übergang wie completed verschiebt den Snapshot seines Platzes direkt vor sich selbst in die kritische Warteschlange.
- Kritische Eventsdevice-status · started · completed
Jedes andere Event reiht sich der Reihe nach ein, bis zu 128, und wird vor gehaltenen Snapshots zugestellt. Eine volle Warteschlange lässt die Verbindung scheitern. Events einer älteren Verbindung oder für einen anderen Desktop erreichen das ViewModel nie.
OkHttp liefert Text-Frames auf seinem eigenen Thread. Ein handgeschriebener Scanner liest nur den type-Schlüssel der obersten Ebene und sortiert den Frame in rpc.response, error, registered, resumed, session oder event, ohne ein JSON-Objekt zu bauen. Antworten, Fehler und Session-Frames werden sofort dekodiert. Events gehen an einen Decoder auf Dispatchers.Default mit Platz für 64 Frames, 32 MiB pro Frame und 64 MiB in der Warteschlange, und ein Frame, der nicht passt, lässt die Verbindung scheitern, die sich dann neu verbindet.
Dekodierte Events durchlaufen RelayEventIngress für die aktuelle Verbindungsgeneration. chat:timeline-updated-Snapshots mit event blocks werden pro Quellgerät, App-Session, Run, Codex-Thread und Agent-Thread zusammengeführt, behalten nur den neuesten, halten höchstens 64 Schlüssel, indem sie den ältesten verwerfen, und erreichen das ViewModel höchstens einmal alle 200 Millisekunden. Jedes andere Event ist kritisch: Es reiht sich in Reihenfolge ein, bis zu 128, und geht vor. Ein Timeline-Übergang für einen Schlüssel verschiebt zuerst den ausstehenden Snapshot dieses Schlüssels in die geordnete Warteschlange, sodass ein Snapshot nie nach dem Übergang ankommt, der ihm folgte. Eine überlaufende kritische Warteschlange lässt die Verbindung scheitern.
Der Activity-Lebenszyklus besitzt die Verbindung
- VerlassenonStop
Das Timeline-Interesse wird sofort zurückgezogen, dann schließt der Client für den Desktop mit seinen Resume-Anmeldedaten. Während Medien laufen, bleibt der Socket offen, aber ohne Timeline-Interesse.
- RückkehronStart · 1,5 s
Die Wiederherstellung startet nach 1,5 s oder sofort, wenn ein desktop_online-Push-Hinweis den ausgewählten Desktop nennt, und nur solange die App mit Netzwerk sichtbar ist. Sie öffnet einen neuen Socket und sendet system.ping mit einem Zeitlimit von 3 s.
- Prüfungen4 s · 16 s · 90 s
Ohne Antwort folgen Prüfungen 4 s nach dem Start der Wiederherstellung und dann alle 16 s, bis zur Wiederverbindungsfrist des Relays oder 90 s, höchstens 100 Mal. Dann zeigt der Desktop offline. Eine Antwort bricht sie ab.
- Interesse-Leasechat:timeline-interest
Wird nur auf einem registrierten Socket gesendet, für die Workspace-Session auf dem Bildschirm, und alle 20 s erneuert. Das Relay behält sie 60 s und verwirft sie, wenn der Socket schließt.
MainActivity.onStop setzt die Desktop-Verbindung aus, außer ein Medienstream spielt, und das Aussetzen verwirft den Client pro Desktop samt seinen Resume-Anmeldedaten. onStart plant die Wiederherstellung nach 1,5 Sekunden, oder sofort, wenn ein desktop_online-Hinweis aus einem Push wartet. Die Wiederherstellung läuft nur, solange die Activity sichtbar ist, das Netzwerk verfügbar ist und der Hinweis den gewählten Desktop nennt.
Die Wiederherstellung prüft den Desktop 4 Sekunden nach dem Start und dann alle 16 Sekunden, bis zur Wiederverbindungsfrist des Servers oder einer Ersatzfrist von 90 Sekunden, mit höchstens 100 Versuchen, und zeigt den Desktop danach als offline. Ein device-status-Presence-Event wird ignoriert, wenn es von einer anderen Relay-Instanz kommt als der, an die der Client gebunden ist.
Das Timeline-Interesse folgt der Sichtbarkeit der Activity. WorkspaceTimelineSyncGate ist die eine Stelle, die inhaltstragende Timeline-Arbeit erlaubt, und sein Bereich existiert nur, solange die Activity auf dem Workspace-Ziel mit gewählter Session, gewähltem Projekt und gewähltem Desktop sichtbar ist. Der Interesse-Koordinator sendet chat:timeline-interest mit der Basis-Session-ID nur über den aktuell registrierten Socket, öffnet dafür nie eine Verbindung, erneuert es alle 20 Sekunden und zieht es zurück, wenn sich der Bereich ändert. Ein Socket, der nach onStop für die Medienwiedergabe offen bleibt, trägt deshalb kein Timeline-Interesse.
Andere Desktops als der gewählte erhalten kurzlebige Clients, die nur system.ping, session.listRecent und run.list senden dürfen, genug, um zu zeigen, ob sie antworten und woran sie zuletzt gearbeitet haben.
Keystore, Anmeldung und Abmeldung
Geheimnisse werden mit AES-GCM-Schlüsseln im Android Keystore verschlüsselt, mit zufälligen IVs und 128-Bit-Tags, und der Chiffretext liegt in SharedPreferences. Getrennte Schlüssel-Aliasse schützen das App-JWT, die Geräteidentität, die Outbox, den Ansichtszustand des Arbeitsbereichs, die Session-Reihenfolge des Projekts und die Auswahl des Projektordners. Backups sind aus, und die Regeln für Backup und Datenextraktion schließen jede Domain aus. Keystore-Schlüssel verlassen nie das Gerät, also wäre eine wiederhergestellte Kopie des Chiffretexts ohnehin unlesbar.
Die Anmeldung fragt ab, statt umzuleiten. Die App erzeugt eine Polling-ID, ein CSRF-Token und eine S256-PKCE-Challenge, öffnet /auth/auth0/initiate-login in einem Custom Tab und fragt /auth0/poll-status alle 2 Sekunden ab, bis zu 60 Mal. Sie tauscht Code und Verifier selbst bei Auth0 ein und erhält das App-JWT von /auth0/finalize-login, deshalb ist kein App Link beteiligt. Android stellt den Anmeldefortschritt nach einem Prozessverlust nicht als gespeicherten UI-Zustand wieder her; ein neuer Versuch ersetzt den vorherigen. Das Token wird über /api/auth0/refresh-app-token erneuert, sobald es weniger als 5 Minuten vor dem Ablauf steht, und ein Zähler für Sitzungsgenerationen verhindert, dass eine spät fertige Erneuerung oder Anmeldung eine Abmeldung überschreibt.
- ServeraufrufeDELETE /api/devices/{id} · POST /api/auth/logout
Beide erfolgen nach bestem Bemühen, und ein Fehler bei einem der beiden wird ignoriert. Der Logout-Aufruf entfernt das gespeicherte App-JWT in einem finally-Block, ob der Server geantwortet hat oder nicht.
- Session-GenerationsessionGeneration
Der Logout und jeder neue Anmeldeversuch erhöhen sie. Eine Erneuerung oder Anmeldung, die früher begann, besteht ihre Generationsprüfung nicht und speichert nichts.
- Outbox-ZaunAccountBoundOutboxScope
Neue Outbox-Arbeit startet auf einem bereits abgebrochenen Job, und die Bereinigung wartet in einem nicht abbrechbaren Kontext, bis die ausgemusterten Jobs fertig sind. Ohne Token hat die Outbox keinen Besitzer, ihre Schreibvorgänge scheitern also ohnehin.
- Lokale BereinigungclearAll() · commit()
Zuerst muss die verschlüsselte Outbox entfernt werden, dann die Session-Reihenfolge, die Projektordner und der Ansichtszustand. Danach werden die zwischengespeicherte Desktop-Liste und der Sync-Zustand des Push-Tokens gelöscht, und die Anmeldung öffnet sich.