Gradle/Firebase: firebase-messaging + work-runtime-ktx in the catalog and
:app; com.google.gms.google-services applied CONDITIONALLY (only when
app/google-services.json exists) so the repo builds green without any
Firebase config; committed google-services.json.example + README section
(CI-injected official builds / self-build drop-in / v1.x runtime
FirebaseOptions path); real config gitignored. push/PushBinding.kt is the
availability seam: no Firebase -> every push path no-ops.
Registration (:core:data push/DeviceRegistrar): stable DataStore UUID
deviceId; POST /api/devices/register {token, platform:'phone', deviceId}
fanned out to EVERY paired hub on app start, on pairing (roster addition),
and on onNewToken; transient failures retry via a per-hub WorkManager
unique work item; best-effort DELETE on sign-out while the hub's JWT still
works.
Service (fcm/HapiFirebaseMessagingService): data-only contract v1 decoding
in :core:data push/PushPayload — channels permission_requests(HIGH) /
ready / task_notifications (created on app start), type-<sessionId>
coalescing tags, severity accent colors, notifySummary-driven ready
bodies, unknown type/contractVersion degrade to plain title/body (default
channel, no actions). Suppress-when-open: foreground + that session's
chat composed -> skip (SSE already shows it). Tap -> internal MainActivity
intent route (no public URI) -> existing navigation opens the chat.
Actions: permission-request Allow/Deny and ready/task RemoteInput Reply +
Dismiss -> NotificationActionReceiver -> expedited CoroutineWorkers
(approve/deny {} bodies, reply {text, localId}) through on-demand
HubSessions built from stored credentials (HapiWorkerFactory +
Configuration.Provider + on-demand WorkManager init — no HubGraph needed
in background); notification updates pending -> done / "Already handled"
(404 request-gone) / inactive / failed. Multi-hub: payload has no hub URL,
so workers try the ACTIVE hub first, then other paired hubs on 404
session-miss (single-hub users always hit first try).
Hub check: android.priority=HIGH is already set unconditionally for every
FCM message (hub/src/fcm/fcmService.ts) — no hub change needed.
Tests (34 new, :core:data): payload keys/severities/unknown contract
version, channel routing, suppress-when-open; registrar fan-out /
addition-only / retry-on-transient / null-token no-op via fake seams;
action wire bodies + Bearer header + multi-hub resolution through a real
HubSession against two MockWebServers. Full gate green with AND without
google-services.json (protocol 227, data 182, app 96 tests; debug +
release/R8/lintVital assemble).
Composer attachment tray wired end to end: "+" bottom sheet (photo
library / camera / files), upload-on-pick against POST upload
(JSON+base64), per-chip uploading -> ready/failed states with retry and
best-effort delete-on-remove, AttachmentMetadata riding SendMessageRequest
and the optimistic row so user bubbles thumbnail instantly, and a
UserTextBlockView upgrade decoding wire previewUrl data URLs (web-sent
messages thumbnail too).
Mobile-data compression policy (differs from web, which uploads
originals): recompressible images over 4 MB downscale to 2048 px JPEG
q85 (filename swaps to .jpg); GIF/SVG/non-images keep originals; hard
50 MB reject with a notice, plus a capped read guarding unknown-size
picks. previewUrl embeds a <=512 px JPEG thumb instead of the web's
full-size data URL to keep send bodies small.
Simplifications noted in KDoc: attachments do not persist in drafts v1
(holder onCleared discards un-sent uploads after best-effort deletes);
inactive sessions fail the upload chip until a text send auto-resumes.
Scheduled sends guard the wire constraint (scheduledAt excludes
attachments) with a loud check.
Seam: ChatSessionApi now extends AttachmentUploadApi (HapiApi methods
gain override); camera captures use a FileProvider cache scratch,
rememberSaveable across rotation.
Tests: pure policy decisions (plan/sample-size/filenames/data URLs),
controller state machine incl. mid-upload removal orphan cleanup,
VM send/retry/refusal flows with metadata assertions, MockWebServer
wire shapes for upload + delete. Full gate green (protocol/data/app
tests + assembleDebug).
Interaction layer turning the read-only chat into a working remote control:
- Composer: multiline input bar with optimistic sends (appendOptimistic ->
POST -> status settle), queue-by-default delivery with a long-press
Send&Steer intent while a turn is active, abort button during thinking,
tap-to-retry on failed rows, per-session drafts (DataStore, hub-scoped
keys), attachment chip seam for M4.
- session_inactive (409) recovery: one POST /resume then retry; a
superseding session id seeds the new window, migrates the draft and
emits SessionSuperseded for renavigation (web resolveSessionId parity).
- Queued bar: uninvoked sends with Cancel (DELETE; invoked-race ingests the
authoritative row as sent), Edit (cancel + composer prefill, draft-kept
guard) and Steer (POST steer; invoked answers reconcile a missed consume).
reconcileQueuedState now runs on chat open and on session-pipe gap.
- Permission actions: flavor-exact bodies mirroring PermissionFooter.tsx --
claude {} / allowTools / mode:acceptEdits, codex-family decision:
approved / approved_for_session / abort -- plus AskUserQuestion flat
answers and request_user_input nested answers forms; optimistic
Resolving/AlreadyHandled overrides settled by the agentState patch.
- Session config sheet: catalog-driven permission-mode picker, claude
static model/effort catalogs (ported to :core:protocol catalog), codex
models via GET /codex-models (new HapiApi endpoint + wire types) with
per-model reasoning efforts; optimistic detail updates rolled back to
server truth on error.
- Lifecycle: ProcessLifecycleOwner -> SseEngine.setLifecycleForeground +
POST /api/visibility per subscription (VisibilityReporter fed by the new
SyncTargets.onHandshake hook); the global SSE pipe moved from the session
list VM to HubGraph lifetime (GlobalSsePipe) so queued/consumed
bookkeeping and list badges stay fresh while a chat is open.
- Tests: VM-level interaction suite (optimistic send/failure/retry,
inactive-resume both id paths, cancel invoked-race, steer, exact
approve/deny body JSON incl. both answers formats, config optimistic +
rollback, drafts) + GlobalSsePipe tests; full gate green (554 tests,
assembleDebug).
:core:protocol window/ — pure state machine ported function-for-function from
web/src/lib/message-window-store.ts + messages.ts: merge-by-(id|localId) with
optimistic echo reconciliation, position ordering (invokedAt ?? createdAt, seq,
ASCII id tie-break), trim-preserving-queued with the codex agent-run budget,
epoch reset handling, latest-replace with request-baseline identity
preservation, consumed/cancelled/queued-reconcile transitions, tail/history
modes, hydrate/persist shapes. MessageRetention ports the null-decision tree
of normalizeDecryptedMessage (dedup with the B-M2a pipeline port flagged).
:core:data store/ — per-session MessageWindowStore (StateFlow + Mutex,
single-flight tail sync with trailing drain, fetchOlder with epoch-reset
resync, SSE ingest hooks, optimistic sends, queued-state reconciliation,
seedFrom for resume id changes) + WindowSnapshots (atomic JSON files, LRU 10;
JsonSnapshotStore dedup TODO) + MessageWindowStores registry. Minimal
MessagesApi interface (sealed MessagesQuery) extracted over the two message
endpoints, implemented by HapiApi.
Gate: PaginationFixtureTest replays all 11 shared/fixtures/pagination scripts
against the real store — requests, older-load outcomes, reconcile candidates
and the final window projection all exact — 11/11 green; plus targeted
concurrency/snapshot/seed unit tests. :app:assembleDebug green.
:core:data app.hapi.data.sse — the /api/events transport per
docs/api/client-contract/sse.md (reference web/src/hooks/useSSE.ts):
- SseConnection: SseTransport seam (Connected/Event/Failure flow) with the
okhttp-sse implementation on a dedicated client (readTimeout=0, no cache,
own dispatcher), buildEventsUrl, Last-Event-ID header on resume, and an
acceptEncodingIdentity fallback flag for the gzip escape hatch.
- ReconnectPolicy: normative constants + pure backoff schedule (immediate
first retry, 1s..30s exponential, 300s ceiling after 8 attempts, 0..500ms
injected jitter).
- SseEngine: per-key (global / session:<id>) connection loops — handshake
gate on connection-changed{status:connected} with ok/gap resume verdict,
per-key cursors advanced only after the downstream hand-off
(at-least-once), 10s connect deadline, 90s staleness watchdog ticking 10s,
one silent 401 re-auth per cycle costing no backoff attempt, background
retry deferral + 45s foreground stale check; all timing via injected
delay/clock for virtual-time tests.
- SyncEventRouter: 13-type union fan-out to the SyncTargets seam (M2 wires
stores), handshake gap -> requestFullResync, Unknown ignored.
Tests (32): virtual-time engine suite (fake transport + turbine), policy
schedule with seeded jitter, router mapping, and MockWebServer integration
through the real okhttp transport — including proof that gzip SSE frames
surface incrementally (flush-per-event body withheld behind a throttle).
Gzip also verified against a live local hub (Content-Encoding: gzip,
handshake decoded instantly, heartbeat +30.0s mid-stream).