Files
hapi/android
3873e58496 fix(web): keep streamed reasoning/text block ids stable across snapshot rows (#1741)
* fix(web): keep streamed reasoning/text block ids stable across snapshot rows

Streaming snapshots of one stream (pi/codex reasoning and text) arrive as
separate message rows, and the window store retires older rows as newer
snapshots land. The timeline derived the block id from whichever row was
first seen, so the id (and the threadMessageId built from it) churned on
every snapshot, remounting the rendered reasoning panel mid-stream and
replaying its open animation — the panel visibly flashed/re-rendered on
every snapshot tick.

Derive the block id from the stream id when present (unique per stream,
stable across snapshot rows) so the block is updated in place and the
smooth streaming keeps appending to the previous text. Row-derived ids
remain the fallback for content without a stream id.

Also rerun gen:fixtures to refresh the two golden fixtures affected by
the new id shape.

* fix(ios,android): mirror stream-stable block ids in native chat ports

The native HapiKit (Swift) and protocol (Kotlin) chat pipelines are ports
of the web reducerTimeline and are pinned by the same golden fixtures in
shared/fixtures/chat. After the web-side change to derive streamed
reasoning/text block ids from the stream id, the ports still produced
row-derived ids, so the iOS/Android fixture conformance suites went red
on the two refreshed fixtures.

Apply the same streamId-first id derivation (row-derived fallback kept)
to both ports so all three pipelines project identical block ids.

* fix(web,ios,android): reject blank stream ids as block identity

Blank ('' or whitespace-only) stream ids are not streams per the wire
semantics in shared/src/messages.ts (readReasoningStreamId trims before
accepting). The previous nullish fallback let accepted payloads carrying
blank ids through, so every such row shared one empty block id: the
merge maps collided and assistant-ui occurrence suffixes churned with
list position, reintroducing remounts.

Normalize with a trim guard in all three pipelines (web, HapiKit,
protocol) and add a web regression test covering both empty and
whitespace-only ids.

* fix(ios): use normalized stream id for block construction identity

The blank-id guard was applied to lookup and map insertion but block
construction still read the raw optional, so accepted payloads carrying
blank/whitespace ids produced blocks sharing one blank SwiftUI identity
instead of falling back to row-derived ids (web/Android already used the
normalized local). Hoist the nonBlankStreamId result and reuse it for
lookup, block identity, and insertion in both the text and reasoning
branches.

Also add native coverage for stream identity: stream-id derivation for
text/reasoning plus blank ('' and whitespace-only) fallbacks, which the
golden fixtures do not exercise.

* fix(web): pin blank stream-id identity contract in golden fixtures

Update the two stale fixture descriptions (stream-keyed blocks are now
keyed by the stream id, not the first message) and add a generated
conformance fixture covering empty and whitespace-only codex data.id
values for both reasoning and text: blank ids are not stream identities,
so each payload keeps its own row-derived block id instead of collapsing
onto a shared blank identity. Web, iOS, and Android all run this same
golden fixture.

* feat(hub): make title provider max_tokens and timeout env-tunable

Reasoning models used as title providers (e.g. GLM thinking models) need
more than 64 completion tokens and more than the hardcoded 10s timeout to
emit a title, and the only workaround was patching the compiled binary
after every install.

Expose both knobs via HAPI_TITLE_PROVIDER_MAX_TOKENS and
HAPI_TITLE_PROVIDER_TIMEOUT_MS, following the existing
HAPI_TITLE_SUGGESTION_RATE_LIMIT pattern; defaults are unchanged.

* docs(hub): document title provider max_tokens/timeout env knobs

Add the two new HAPI_TITLE_PROVIDER_* variables to the title-provider
configuration table in the installation guide, and extend the provider
test to cover the timeout abort path (the signal fires and rejects the
in-flight request).

---------

Co-authored-by: HongChenGG <HongChenGG@users.noreply.github.com>
2026-09-06 14:20:19 +08:00
..

HAPI Android Companion

Native Android client (Kotlin + Jetpack Compose) for the HAPI hub. Fully independent from the web app; shares only the protocol contract (docs/api/) and the golden fixtures (shared/fixtures/).

  • applicationId: run.hapi.companion · minSdk 26 · target/compileSdk 36
  • Toolchain: Gradle 8.14.2 (wrapper) · AGP 8.11.1 · Kotlin 2.1.21 · Compose BOM 2025.05.00 · JDK 17+ (CI uses 21)

Modules

Module Type Responsibility
:core:protocol pure Kotlin/JVM (no Android) Hub wire types (kotlinx.serialization), chat pipeline port (normalize → reduce → tool groups), message-window/pagination logic, versioned patch application, modes catalog, git output parsers, BindLink pairing-link parsing. M1a landed: wire/ (HapiJson, Session/SessionPatch/SessionSummary, DecryptedMessage, AgentState, Machine, 13-type SyncEvent union via SyncEvents.parse, MessagesResponse), catalog/ (flavors + permission/collaboration modes), patch/SessionPatching.kt (exact port of web/src/lib/sessionPatch.ts), all fixture-verified.
:core:data Android library Transport + persistence. M1b landed — auth/ (JwtPeek, CredentialStore interface + EncryptedPrefsCredentialStore/in-memory, HubUrls origin normalization, HubRegistry roster behind a storage seam, AuthInterceptor + single-flight TokenAuthenticator with ensureFreshToken() and terminal AuthEvents), api/ (HapiApi — plain OkHttp + kotlinx.serialization, one suspend fun per v1 endpoint incl. generated-image bytes via a 256 MB OkHttp cache and the multipart transcription helper; ApiError with (status, code)), HubSession per-hub factory; MockWebServer-tested. M1c landed — sse/: SseEngine (per-key global/session:<id> loops, connection-changed handshake gate with ok/gap resume verdict, per-key Last-Event-ID cursors advanced only after downstream hand-off (at-least-once), 10 s connect deadline, 90 s watchdog, 1 s→30 s→300 s backoff + jitter, background retry deferral + 45 s foreground stale check, one silent 401 re-auth per cycle), OkHttpSseTransport (dedicated client, readTimeout=0, incremental gzip decoding pinned by test, acceptEncodingIdentity fallback), SyncEventRouter → SyncTargets seam; virtual-time tested. Still to come: StateFlow stores + AtomicFile JSON snapshots (M2), FCM registration + WorkManager workers (M4).
:app Android application Compose UI, navigation, deep links (hapicompanion://bind), FCM service (M4), hand-rolled DI (AppGraph, no Hilt). M1d landed — di/ (AppGraph process singletons: Preferences DataStore-backed HubRegistryStorage, EncryptedPrefsCredentialStore, HubRegistry, auth-terminal fan-out; HubGraph per active hub: HubSession + SseEngine wired to ensureFreshToken, recreated on hub switch; LocalAppGraph CompositionLocal + viewModelFactory helper), feature/pairing/ (landing / zxing ScanContract QR scan / manual entry sharing one PairingViewModel: health + protocol check → POST /api/auth → persist + activate), feature/home/ placeholder (hub switcher + sign-out), Navigation.kt (pairing ⇄ home, auth-terminal → pairing with banner), bind deep-link handling in MainActivity.

Dependency direction: :app → :core:data → :core:protocol.

Protocol conformance fixtures

:core:protocol is the porting target for web/src/chat/ and is verified against golden fixtures generated from the web implementation (track K). The test task already passes the fixtures location as a system property:

// core/protocol/build.gradle.kts
tasks.test {
    systemProperty("hapi.fixtures.dir", rootDir.parentFile.resolve("shared/fixtures").absolutePath)
}

Fixture-driven tests (M2) read System.getProperty("hapi.fixtures.dir") — no further build changes are needed when shared/fixtures/** lands. CI re-runs this suite whenever android/** or shared/fixtures/** change.

Building

Requires an Android SDK for :app/:core:data (set ANDROID_HOME or android/local.properties with sdk.dir=...). :core:protocol alone needs only a JDK.

cd android
./gradlew :core:protocol:test        # pure JVM protocol tests (fast)
./gradlew :app:assembleDebug         # debug APK
./gradlew :app:installDebug          # install on a connected device

Without an Android SDK you can still run the protocol suite by configuring only the needed projects:

./gradlew --no-configuration-cache --configure-on-demand :core:protocol:test

CI (.github/workflows/android.yml) runs the protocol tests and :app:assembleDebug on every PR touching android/** or shared/fixtures/**.

Pairing

HAPI is self-hosted: the app talks to a hub you run. Pairing = giving the app a hub URL plus that hub's access token; the app verifies the hub (GET /health, protocol version), exchanges the token for a JWT (POST /api/auth), stores the credentials in EncryptedSharedPreferences (keyed per hub — multiple hubs can be paired, one active at a time), and lands on the session UI. Three entry points:

  1. QR scan — the hub prints two QR codes when started with --relay (also under web Settings → Companion pairing). The in-app scanner accepts both: the companion deeplink (hapicompanion://bind?hub=…&code=…) and the web direct-access URL (…?hub=…&token=…).
  2. Deep link — scanning the companion QR with the system camera opens the app directly with a confirm screen (hapicompanion://bind intent filter).
  3. Manual entry — hub URL + access token, for hubs started without --relay.

Pairing against a development hub

# Start a hub with the built-in HTTPS relay (prints the access token + QR codes).
hapi hub --relay

# Use the printed https:// URL. For a source-tree `bun run dev` hub, put an
# HTTPS reverse proxy or tunnel in front of localhost:3006 first.
adb shell am start -a android.intent.action.VIEW \
  -d "hapicompanion://bind?hub=https%3A%2F%2Fhub.example.com&code=<accessToken>"  # optional: exercises the deep link

The app rejects plain-http hub URLs in manual entry, deep links, QR codes, and restored hub state. The manifest also sets android:usesCleartextTraffic="false"; there is no debug or LAN exemption. Sign-out (home → Sign out) deletes the stored credentials for that hub and drops it from the roster.

Milestones (track B of the native-clients plan)

  • M0 — this scaffold: modules, version catalog, CI, placeholder screen.
  • M1 — foundations: wire types + modes catalog; auth + HapiApi (MockWebServer-tested); SseEngine reconnect state machine + versioned patches (gzip streaming verified); pairing UI + hapicompanion://bind deep link.
  • M2 — read-only chat: chat pipeline port gated on fixtures all-green; session list; MessageWindowStore port; Markdown renderer; read-only chat screen (LazyColumn(reverseLayout = true)).
  • M3 — interaction: composer (optimistic send/queue/steer/drafts), permission approvals UX, session controls (mode/model/abort/resume/rename/archive), new session, dictation.
    • B-M3ce landed — voice dictation: mic button in the composer (RECORD_AUDIO requested at first use), MediaRecorder → m4a/AAC, provider discovery via GET /api/voice/transcription/providers (first standard-capable provider; a hub without one shows a notice), upload through the multipart POST /api/voice/transcription, transcript appended at the composer text with a space separator; DictationController is a plain seam over recorder + API, JVM-tested with fakes. Slash commands: typing a lone /token opens a dropdown merging the session's metadata.slashCommands names with the GET /slash-commands RPC list (RPC entries win dedupe; exact → prefix → contains filtering), tap inserts /name (the skills $ trigger is deferred). Session ops: list long-press sheet and chat top-bar overflow gain Rename (PATCH /sessions/:id, optimistic name with roll-forward on failure), Delete (confirm; 409-while-active surfaced), and Reopen for inactive sessions (POST /reopen; a superseding id reuses the supersede path — window seed + draft move + navigate-replace; 422 missing-metadata formatted); chat shows an inactive-session bar ("send to resume, or Reopen").
  • M4 — FCM push (register → notification actions via expedited WorkManager) + files/git viewer, Scratchlist, usage/storage stats.
    • B-M4a landed — FCM push + notification actions. :core:data push/: PushPayload (data-only contract v1 decoding: type/severity/notifySummary parsing, channel routing, type-<sessionId> coalescing tags, unknown type/contractVersion degrade to plain title/body), DeviceRegistrar (registers the FCM token with every paired hub on start/pairing/onNewToken, DataStore-persisted deviceId UUID, WorkManager retry seam, best-effort unregister on sign-out before credentials are wiped), PushHubAccess + PushActionRunner (workers build a HubSession on demand from stored credentials — no HubGraph needed in background — and resolve the owning hub: active hub first, other paired hubs on 404 session-miss). :app: push/PushBinding (Firebase availability gate — no google-services.json → all push paths no-op), fcm/ (HapiFirebaseMessagingService, NotificationChannels — permission_requests HIGH / ready / task_notifications, PushNotifications builder with severity accents + suppress-when-open rule, NotificationActionReceiver → expedited PermissionActionWorker (Allow/Deny → approve/deny {}) and SendMessageWorker (RemoteInput reply → {text, localId}) with pending → done/"Already handled"/failed notification states), WorkManager on-demand init + HapiWorkerFactory, notification tap → internal MainActivity intent route → chat.
  • M5 — polish: zh-CN i18n, OLED/Material You theming, predictive back, LeakCanary pass, Play listing + self-build docs.
    • B-M5a landed — zh-CN localization + in-app language switching (see "Internationalization" below).

Internationalization (B-M5a)

The app ships English (default) and Simplified Chinese (app/src/main/res/values-zh-rCN/strings.xml). Every user-visible string lives in resources; both files carry the same key set (lint MissingTranslation is the gate).

Adding a string

  1. Add it to app/src/main/res/values/strings.xml with a feature-prefixed key matching the existing convention (chat_, sessions_, files_, scratchlist_, pairing_, settings_, new_session_, notif_, tool_ for tool-card titles). Dynamic values use positional format args (%1$s, %2$d); count-dependent copy uses explicit _one/_many keys (the deliberate house style — no <plurals>).
  2. Add the zh-CN twin to values-zh-rCN/strings.xml. Terminology source of truth is the web corpus web/src/lib/locales/zh-CN.ts — reuse its product terms (会话 session, 机器 machine, 权限模式 permission mode, 工作树 worktree, 草稿夹 scratchlist, 语音输入 dictation, 用量 usage, 智能体/代理 agent). Technical identifiers (model ids, flavor names like Claude/Codex, permission-mode catalog labels, CLI flags) stay untranslated, matching the web's choices.
  3. Reference it: composables via stringResource(R.string...). ViewModels stay string-free — transient notices are semantic sealed types (ChatNotice, ScratchlistNotice, PairingError, DictationErrorKind) resolved at the UI layer; where a ViewModel genuinely composes display text it takes a small Strings seam (FilesStrings, FileViewerStrings, NewSessionStrings) whose defaults are the English values (JVM tests construct without arguments) and whose production instance is resource-resolved in the Navigation holders. Server-provided error text passes through verbatim.

Language switching

Settings → App language offers Follow system (default) / English / 简体中文. The choice persists in LanguagePrefs (DataStore) and applies immediately via AppCompatDelegate.setApplicationLocales:

  • MainActivity extends AppCompatActivity (theme parent Theme.AppCompat.DayNight.NoActionBar) so per-app locales work back to API 26; on API 33+ the framework LocaleManager takes over (the app also declares android:localeConfig for the system App-languages screen).
  • The manifest opts into appcompat's autoStoreLocales (AppLocalesMetadataHolderService meta-data), which re-applies the stored choice synchronously on cold start.
  • Surfaces that resolve strings from the application context — FCM notifications, WorkManager result updates, notification-action receivers — wrap their context with localizedForAppLanguage(AppGraph.appLanguage) (di/LocaleContexts.kt), since per-app locales only retarget activity contexts below API 33.

Out of scope on purpose: :core:protocol presentation strings (getEventPresentation, tool-group activity titles) stay English — the web does not translate them either, and terminology parity with the web wins.

Firebase / push

FCM needs a Firebase project binding, which is deliberately optional: the com.google.gms.google-services plugin is applied conditionally (only when app/google-services.json exists — see app/build.gradle.kts), so the repo always builds green without any Firebase config. Without one, FirebaseApp never initializes, PushBinding.isAvailable reports false, and every push code path (registration, FCM service, workers, the notification-permission prompt) no-ops — the app behaves like pre-M4a.

To enable push:

  1. Official builds: CI injects the default Firebase project's google-services.json before assembling (the file is gitignored; app/google-services.json.example documents the expected shape).
  2. Self-builds: create your own Firebase project, add an Android app with your applicationId (default run.hapi.companion), download google-services.json into android/app/, and rebuild.
  3. Hub side: point the hub at the same Firebase project — FCM_SERVICE_ACCOUNT_PATH (or fcmServiceAccountPath in ~/.hapi/settings.json; the project id comes from the JSON itself, see docs/api/native-companion-contract.md). The device registers itself with every paired hub (POST /api/devices/register) on pairing, app start, and token rotation, and unregisters on sign-out.

Multi-hub note: the FCM payload does not name the sending hub (contract v1), so notification actions resolve it — the workers try the active hub first, then the other paired hubs when a hub answers 404 for the session. Single-hub setups always hit on the first try. Tapping a notification opens the session against the active hub.

Planned for v1.x: runtime FirebaseOptions handed out by the hub, so self-builds get push without baking a config into the APK. That lands entirely behind the existing app/.../push/PushBinding.kt seam.

Release signing

Same philosophy as Firebase: the repo carries no secrets and builds green without them. :app:bundleRelease produces an unsigned AAB unless an upload key is configured via gradle properties (user-global ~/.gradle/gradle.properties), environment variables (CI secrets), or android/local.properties (gitignored; same property names — the conventional machine-local home, loaded explicitly since it is not part of gradle's own property chain):

gradle property env meaning
hapiUploadKeystore HAPI_UPLOAD_KEYSTORE keystore path (~ ok)
hapiUploadKeystorePassword HAPI_UPLOAD_KEYSTORE_PASSWORD store password
hapiUploadKeyAlias HAPI_UPLOAD_KEY_ALIAS default upload
hapiUploadKeyPassword HAPI_UPLOAD_KEY_PASSWORD default: store password

This is an upload key for Play App Signing (Google holds the actual distribution key, so a lost upload key is resettable in Play Console). Generate one with:

keytool -genkeypair -v -keystore ~/.hapi/upload.keystore -alias upload \
  -keyalg RSA -keysize 2048 -validity 10950

Keystores never live in the repo (*.keystore / *.jks are gitignored).