With the default soft-input mode the AppCompat subdecor also resizes the
window for the IME, stacking a keyboard-sized gap on top of imePadding
(device-observed on the chat composer). SOFT_INPUT_ADJUST_NOTHING on
API 30+ leaves Compose as the single keyboard-inset owner; 26-29 keep
adjustResize because the ime() inset backport depends on it.
- decode fs.stat-derived epoch fields leniently (fractional mtimeMs from
real hubs broke machines/files decode and pinned the offline banner)
- offline banner: only a failed sessions fetch counts; machines/baseline
failures are advisory; live SSE emission clears it and seeds the unread
baseline (all-rows-unread gray dot cascade)
- session row: single weighted title (short names no longer truncated at
half width), unread dot moved beside the timestamp
- composer restyle: borderless input pill with inline mic, hand-drawn
vector glyphs replacing emoji icons, 42dp round actions, park-draft
relocated to the session overflow menu
Extraction sweep: every user-visible hardcoded English string in
android/app moved to values/strings.xml with feature-prefixed keys
(sessions_/chat_/files_/scratchlist_/pairing_/new_session_/tool_...);
values-zh-rCN/strings.xml translates all 448 keys, terminology aligned
with web/src/lib/locales/zh-CN.ts.
ViewModels stay string-free: transient notices became semantic sealed
types resolved at the UI layer (ChatNotice, ScratchlistNotice +
ScratchlistImportRejection, PairingError, DictationErrorKind,
SessionListError.DeleteFailed.stillActive); ViewModels that genuinely
compose display text take small Strings seams with English defaults
for JVM tests (FilesStrings, FileViewerStrings, NewSessionStrings);
toolCardPresentation gains a Resources parameter.
Language switching: LanguagePrefs gains SYSTEM (follow system, the new
default); Settings applies AppCompatDelegate.setApplicationLocales
immediately; MainActivity now extends AppCompatActivity over
Theme.AppCompat.DayNight.NoActionBar with autoStoreLocales +
android:localeConfig; FCM/WorkManager surfaces wrap the application
context via localizedForAppLanguage (per-app locales miss non-activity
contexts below API 33).
Verification: :app:assembleDebug green, :app:lintDebug 0 errors
(MissingTranslation clean), full JVM test gate green (176 app tests;
notice assertions updated to the semantic types).
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).