* 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>
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:
- 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=…). - Deep link — scanning the companion QR with the system camera opens the
app directly with a confirm screen (
hapicompanion://bindintent filter). - 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);SseEnginereconnect state machine + versioned patches (gzip streaming verified); pairing UI +hapicompanion://binddeep link. - M2 — read-only chat: chat pipeline port gated on fixtures all-green; session list;
MessageWindowStoreport; 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_AUDIOrequested at first use),MediaRecorder→ m4a/AAC, provider discovery viaGET /api/voice/transcription/providers(firststandard-capable provider; a hub without one shows a notice), upload through the multipartPOST /api/voice/transcription, transcript appended at the composer text with a space separator;DictationControlleris a plain seam over recorder + API, JVM-tested with fakes. Slash commands: typing a lone/tokenopens a dropdown merging the session'smetadata.slashCommandsnames with theGET /slash-commandsRPC 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").
- B-M3ce landed — voice dictation: mic button in the composer (
- M4 — FCM push (register → notification actions via expedited WorkManager) + files/git viewer, Scratchlist, usage/storage stats.
- B-M4a landed — FCM push + notification actions.
:core:datapush/:PushPayload(data-only contract v1 decoding: type/severity/notifySummaryparsing, 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-persisteddeviceIdUUID, WorkManager retry seam, best-effort unregister on sign-out before credentials are wiped),PushHubAccess+PushActionRunner(workers build aHubSessionon demand from stored credentials — noHubGraphneeded in background — and resolve the owning hub: active hub first, other paired hubs on 404 session-miss).:app:push/PushBinding(Firebase availability gate — nogoogle-services.json→ all push paths no-op),fcm/(HapiFirebaseMessagingService,NotificationChannels—permission_requestsHIGH /ready/task_notifications,PushNotificationsbuilder with severity accents + suppress-when-open rule,NotificationActionReceiver→ expeditedPermissionActionWorker(Allow/Deny → approve/deny{}) andSendMessageWorker(RemoteInput reply →{text, localId}) with pending → done/"Already handled"/failed notification states), WorkManager on-demand init +HapiWorkerFactory, notification tap → internalMainActivityintent route → chat.
- B-M4a landed — FCM push + notification actions.
- 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
- Add it to
app/src/main/res/values/strings.xmlwith 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/_manykeys (the deliberate house style — no<plurals>). - Add the zh-CN twin to
values-zh-rCN/strings.xml. Terminology source of truth is the web corpusweb/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. - 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:
MainActivityextendsAppCompatActivity(theme parentTheme.AppCompat.DayNight.NoActionBar) so per-app locales work back to API 26; on API 33+ the frameworkLocaleManagertakes over (the app also declaresandroid:localeConfigfor the system App-languages screen).- The manifest opts into appcompat's
autoStoreLocales(AppLocalesMetadataHolderServicemeta-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:
- Official builds: CI injects the default Firebase project's
google-services.jsonbefore assembling (the file is gitignored;app/google-services.json.exampledocuments the expected shape). - Self-builds: create your own Firebase project, add an Android app
with your
applicationId(defaultrun.hapi.companion), downloadgoogle-services.jsonintoandroid/app/, and rebuild. - Hub side: point the hub at the same Firebase project —
FCM_SERVICE_ACCOUNT_PATH(orfcmServiceAccountPathin~/.hapi/settings.json; the project id comes from the JSON itself, seedocs/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).