Files
hapi/android
be5f37b9bf feat(android): generalize generated media blocks to video/audio/file (#2162)
* feat(android): render chat markdown math with offline KaTeX

Port the web math pipeline to Android chat markdown. A new
MarkdownMath pre-pass in :core:protocol extracts math with web
semantics (remark-math singleDollarTextMath:false + remark-latex-
bracket-math): $$ display flows, $$ text math only when the closer
run equals the opener and stays inside the paragraph, single $
never triggers math (currency stays prose), \(..\)/\[..\] bracket
pairs, backslash-escaped delimiters stay literal, and code spans,
fences, links and link reference definitions are fully protected.
Matches are lifted behind private-use placeholder tokens and
re-inserted as MathInlineNode/MathBlockNode custom nodes, hoisting
display math out of paragraphs while blockquote/list containers
survive the reparse. 37 JVM tests mirror the web suite's cases.

Rendering is a shared offline WebView (ui/markdown/MathWebView.kt)
fed a self-built HTML document (MathDocument.kt) against bundled
assets/katex/ (KaTeX 0.16.45, same version as web). The view is
network-blocked (blockNetworkLoads + shouldInterceptRequest
refuses http/https/ws/wss) with file/content access disabled;
asset URLs still resolve through the file:///android_asset/katex/
base URL. Theme color and font size are injected per render and
the document reports its height through a JS bridge
(ResizeObserver + onPageFinished re-measure). KaTeX failures
follow the web's three-tier semantics, degrading to monospace
raw TeX.

Inline \(..\) and single-paragraph $$ math degrades to monospace
TeX for v1: AnnotatedString cannot host a WebView and one WebView
per fragment is too costly in chat lists. The parser still emits
MathInlineNode so a future renderer can upgrade inline rendering
without touching the extraction layer.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): convert CSS pixels correctly at the math WebView boundary

The JS bridge reports scrollHeight in CSS pixels, but KaTeXMathBlock
treated the value as Android physical pixels and divided it by the
screen density when sizing the modifier: on a density-3 device a
reported 90 CSS px block collapsed to 30dp instead of ~90dp. The font
conversion had the opposite mismatch — 16sp chat text went through
TextUnit.toPx() (physical pixels) and was injected as `font-size: 48px`
CSS, making formulas render ~3x too large on the same screen.

Define the two boundary conversions explicitly as pure functions next
to the document builder and unit-test them:

- spFontToCssPx: CSS px and sp are both density-independent, so only
  the user's font-scale preference crosses the boundary; density never
  enters (16sp stays 16 CSS px at any density, 21 at fontScale 1.3).
- cssPxToDp: reported CSS px map 1:1 to dp at the default WebView
  scale, multiplied by WebView.scale when the view is zoomed.

The document now also pins the viewport with
`width=device-width, initial-scale=1`, making the 1 CSS px = 1 dp
default scale an explicit property of the document rather than a
WebView default, with a regression test asserting the meta tag.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(android): refresh the math WebView height callback across spec changes

KaTeXMathBlock keyed its height state with remember(spec) but assigned
MathWebView.onHeightChanged inside AndroidView.factory, which runs only
when the Android view is created. The callback therefore captured the
initial spec's state object: when LaTeX, theme color or font size
changed, remember(spec) minted a new state while AndroidView retained
the existing view and its stale callback, so every measurement from the
reloaded document updated the orphaned state and the current block
stayed at its 22dp minimum.

Keep one stable holder (MathViewHeightState) remembered unkeyed for the
mounted view, reset it explicitly in onRender when the spec changes, and
re-point the view's callback plus reset the height inside
AndroidView.update before rendering — the creation-time callback then
always feeds live state. JVM tests drive the holder through spec
changes on one mounted view (the pre-fix wiring cannot compile against
this shape: the factory-captured remember(spec) state is exactly the
orphan the holder removes).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(protocol): bound dollar flow math closing to the enclosing container

findFlowClose kept scanning after the opening line left its container
and accepted an unprefixed closing run, so `> $$\n> x\n\nOrdinary
prose\n\n$$\ny\n$$` paired the quoted opener with the later top-level
`$$`: the prose was swallowed into the formula and the final `$$` lost
its own opener. remark-math (verified against micromark-extension-math,
whose math flow is `concrete` and dies with its container) renders the
quote's math, the prose and a separate top-level equation instead.

Bound matching to the opening container, distinguishing container
shapes: a closer is only accepted from a line that still carries the
container prefix; a blank line ends a blockquote (a following `> $$`
opens a NEW quote, while a `>`-only line is an in-quote blank the
container survives); a list item may resume after blank lines (loose
lists), so blanks are skipped and the next content line decides; an
unrecognized prefix is plain <= 3 space indentation, not a container,
and keeps the unbounded scan (blank lines and fences stay math content).

Regression tests: the quoted example (prose stays prose, the later
equation keeps its own opener), a list-container variant, a
blank-line/new-quote guard, plus two in-container close locks — the
first three fail on the pre-fix scan.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(android): render mermaid fences as offline diagrams

Port the web MermaidDiagram component to Android chat markdown. A fence
whose language is exactly `mermaid` (case-sensitive, like the web's
MARKDOWN_COMPONENTS_BY_LANGUAGE key) now dispatches to MermaidDiagramView
instead of the plain code block; every other fence keeps the CodeBlock
path.

Rendering runs in a hardened offline WebView fed a self-built HTML
document against bundled assets/mermaid/mermaid.min.js (Mermaid 11.14.0,
curl-fetched, sha256-identical to the web app's locked dependency). That
dist file is a self-contained classic script assigning globalThis
mermaid - no imports, no runtime fetches - so no ES-module/CORS
workaround is needed. The view shares the KaTeX skeleton: network stack
blocked, file/content access off, asset URLs through the
file:///android_asset/mermaid/ base URL (OfflineMathWebViewClient
generalized to OfflineMarkdownWebViewClient, no behavior change).

Behavior mirrors the web component line by line: initialize with
startOnLoad:false, securityLevel:'strict', suppressErrorRendering:true
and the web's dark/light themeVariables palettes verbatim (theme flips
re-render); the validate-then-render gate parses with suppressErrors
first and never calls render() after a failed validation, re-parsing
once without suppression only to surface a human-readable reason; svg
element ids are per-instance (`mermaid-<n>`, web useId) so the scoped
styles mermaid emits cannot collide across diagrams in one message;
wide diagrams scroll horizontally inside the document while the svg is
centered at max-width:100% and height:auto.

The code header (language chip + copy) stays above the body like the
web's retained DefaultCodeBlock header; until the diagram renders - and
on failure - the raw source is shown below it (the block's source exit),
with the failure notice appearing only for actual errors so the loading
window never flashes a render-error line. mermaidFallbackUi maps state
to those surfaces as a pure function.

15 JVM tests cover dispatch, offline-ness, palettes, security flags,
the validation gate structure, id uniqueness, generation filtering,
script-breakout safety and the fallback mapping; the driver's runtime
semantics were additionally validated against the real bundle in jsdom
(flowchart/sequence render, invalid source surfaces the parse reason,
hostile labels are rejected/sanitized under strict).

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): convert CSS pixel heights correctly in the mermaid WebView

Both height reporters feeding MermaidDiagramView — the document bridge's
report() (documentElement/body scrollHeight) and
OfflineMarkdownWebViewClient's onPageFinished re-measure — return CSS
pixels, but the rendered branch ran the value through
with(density) { heightPx.toDp() }, which treats it as Android physical
pixels and divides by display density: on a 3x display a diagram
measuring 180 CSS px received a 60dp box and was clipped into an
unnecessarily scrolling WebView.

Fix it exactly like the KaTeX base now does: the document pins
`width=device-width, initial-scale=1` (making 1 CSS px = 1 dp at the
default WebView scale an explicit property of the document rather than a
WebView default), and the reported value crosses the boundary through
the shared density-independent cssPxToDp helper — extracted as the pure
mermaidRenderedHeightDp so the composable no longer holds any density
conversion at all, keeping both markdown renderers on one boundary
contract. (The KaTeXMathBlock repeat of the same bug is fixed by the
rebased base; MathWebView.kt arrives converted already.)

JVM tests: the viewport pin is asserted against the built document
(demonstrated failing on the pre-fix template) and the conversion math
is driven through the pure helper — 180 CSS px stays 180dp because the
boundary carries no density parameter, mirroring the base's
MathDocumentTest contract.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* feat(android): generalize generated media blocks to video/audio/file

The hub's /generated-images/:id endpoint already serves any mime type
(video, audio, arbitrary files arrive as the same generated-image wire
block with a mimeType), but the Android chat rendered every block as an
image. This ports the web GeneratedImageCard generalization so the card
routes by display intent, exactly like web generatedInlineMedia.ts:

- mimeType routing is the web's literal prefix check: video/… gets a
  player, audio/… an audio player, image/… or null keep the existing
  image treatment (unchanged AsyncImage thumbnail + tap-to-zoom dialog),
  everything else is a file card (no separate "unsupported" class —
  unknown mimes ARE the file UI, matching web G3/G8).
- non-image media never downloads on its own: "Load video" / "Load
  audio" / "Prepare download" gate buttons (web's explicit-intent rule
  G4; English labels verbatim from the web card), pulsing skeleton
  while fetching (G7), and the shared "{label} is unavailable. {error}"
  error line (G8). The wire carries no tiny/poster fields, so video
  renders bare — nothing to special-case.
- every kind now sits in the web's G14 card shell with the localized
  "{label}: {fileName}" hint header; images additionally surface the
  error line on Coil failure instead of a silent empty hole.
- downloads go through the same authed OkHttp client Coil uses (JWT +
  silent 401 re-auth + ETag disk cache), staged into a temp file and
  atomically renamed under cache/generated-media/; a completed file
  short-circuits later fetches (immutable, ETag-stable content) and
  non-2xx maps to "HTTP <code>" exactly like the web's ApiError.
- video plays in-app via framework VideoView + MediaController (no new
  media dependencies: Media3 is not on the classpath and pulling it in
  for this card is not worth the MB); audio is a compact
  play/pause + scrub + m:ss row over MediaPlayer (web's native <audio>
  chrome degraded: volume/speed omitted, no waveform — web has none);
  files get Open (ACTION_VIEW over the existing FileProvider, new
  generated-media cache path) and Save (ACTION_CREATE_DOCUMENT picker)
  outlets in place of the browser download link.
- ChatMedia gains a nullable fetchGeneratedMedia field (default null:
  previews/tests degrade to the unavailable line, so stack-only call
  sites merge unchanged).

17 new strings added to en/ru/zh-CN in one commit, mirroring en key
order (KEYSYNC clean; zh count equal — its pre-existing regional order
drift is untouched). 10 JVM tests cover routing (including case
sensitivity and null-mime), mount-gating, label/action mapping, cache
file name sanitizing (path traversal), the MockWebServer round-trip
with status mapping + cached-file short-circuit, and the time
formatter. Gate: :app:testDebugUnitTest :core:data:test
:core:protocol:test :app:lintDebug :app:compileDebugAndroidTestKotlin
BUILD SUCCESSFUL.

Co-Authored-By: Claude Code <noreply@anthropic.com>
via [HAPI](https://hapi.run)
Co-Authored-By: HAPI <noreply@hapi.run>

* fix(android): refine generic file MIME types from the filename

The file card passed the wire mimeType straight through to ACTION_VIEW,
substituting application/octet-stream only when the wire type was null
(r4370795783). Passing octet-stream explicitly defeats Android's viewer
matching: a PDF viewer registered for application/pdf is not matched by
an intent whose type says application/octet-stream, so PDFs (and every
other ordinary file whose hub type was octet-stream or missing) opened
with no eligible activity.

resolveFileMimeType now passes a specific wire type through untouched,
but when the wire type is missing, empty, or octet-stream (compared
case-insensitively) it re-derives the type from the original filename's
extension through an injected resolver — android.webkit.MimeTypeMap at
the call site, following the established AttachmentPreparer /
AndroidShareIntake pattern (lowercased extension, slash/wildcard sanity
check on the resolved value). Unknown extensions keep the generic
octet-stream fallback. The resolved type feeds both outlets: the Open
ACTION_VIEW intent and the Save ACTION_CREATE_DOCUMENT contract.

The resolver is a required parameter rather than a default, which keeps
GeneratedMedia.kt free of android.* imports and forces the seam: 3 JVM
tests drive it with a fake table (pdf->application/pdf via extension for
null/octet-stream/empty wire types; unknown extension and no extension
-> octet-stream fallback; specific wire types pass through untouched
even when the extension would map elsewhere). All three fail on the
pre-fix code by construction — resolveFileMimeType did not exist and the
null-wire PDF case asserted the old octet-stream handoff.

Gate: :app:testDebugUnitTest --tests GeneratedMediaTest BUILD SUCCESSFUL
(13 tests, 0 failures).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(android): bound generated media cache names by UTF-8 bytes

sanitizedGeneratedMediaFileName capped the stem at 96 UTF-16 chars and
relied on that alone (r4370795787). Android filename limits are byte
limits (NAME_MAX 255): 80 hanzi + ".pdf" is a 122-char / 281-byte
component once the UUID imageId prefix is attached (verified against the
pre-fix logic: 36-byte id + 1 + 240 + 4 = 281 bytes), so long Chinese
names failed the atomic cache rename outright.

The complete component — id prefix + stem + extension — is now assembled
under a 255-byte UTF-8 budget, with pathological ids and extensions
capped at 64/32 bytes so the stem always keeps room. The stem keeps its
historical 96-char cap (ASCII names keep their exact prior shape), bytes
beyond the budget are cut from the stem only, on code-point boundaries,
and the extension always survives intact. The char-level whitelist
already drops surrogate halves (a lone half is never a letter), so every
surviving char is one complete BMP code point; the truncation helpers
are additionally pair-safe for any future code-point-aware whitelist.

JVM tests: the reviewer's UUID + 80-hanzi ".pdf" case lands at <= 255
bytes with the extension and id prefix preserved (red on the pre-fix
logic, which produced exactly 281); ".tar.gz"-style extensions survive
stem truncation; a battery of multibyte names re-decode byte-identically
under the cap; ASCII names keep their historical shapes ("i-" + 96 a's,
"img-1-media", "id-x.bin", "i-截图01.png").

Gate: :app:testDebugUnitTest --tests GeneratedMediaTest BUILD SUCCESSFUL
(16 tests, 0 failures).

Co-Authored-By: Claude Code <noreply@anthropic.com>

* test(android): cover audio playback wiring and the file outlets' types

The play/pause/seek decisions and the MediaPlayer lifecycle lived inline
in the audio composable, which made them unreachable from JVM tests (no
emulator here; androidTest only compile-checks). Extract them into
GeneratedAudioSession over a GeneratedAudioPlayer seam — the app side
adapts android.media.MediaPlayer, tests pass a scriptable fake — and the
card now collects the session's flows, with DisposableEffect.onDispose
forwarding to session.release().

JVM coverage through the fake:
 - prepare wires the data source + prepareAsync and reports duration on
   the prepared callback; pre-preparation toggles/seeks stay inert;
 - the toggle maps to start/pause (turbine on the playing flow) and
   natural completion parks the row;
 - seekToFraction lands fraction*duration and clamps to [0, duration],
   reading the position back afterwards;
 - pollPosition only reads while playing (the 250 ms LaunchedEffect);
 - release — the dispose hook — releases the framework player exactly
   once across double-dispose, stops playback, and every later control
   call is inert (a released MediaPlayer throws);
 - player errors and a failing data source both map to the failed line
   and the row goes dead.

The file card's Open/Save construction moves behind generatedFileOutlets
so a JVM test pins that ACTION_VIEW and ACTION_CREATE_DOCUMENT both
carry the *resolved* MIME type (inferred pdf, generic fallback, specific
wire passthrough) plus the suggested save name — android.content.Intent
is not on the unit-test classpath. A MockWebServer fetch with the
reviewer's 80-hanzi + UUID name proves the byte-bounded component really
round-trips the local filesystem: under 255 bytes, extension kept, no
staging litter beside the final file.

These tests fail on the pre-change code by construction: the session,
seam, and outlets did not exist and the logic was not reachable from
the JVM. Explicit loading (loadsOnMount gate), authenticated caching
(hubGraph.session.imageClient: JWT + silent 401 re-auth + ETag cache),
and download cleanup (staged temp deleted on failure, atomic rename on
success) are untouched — only their test surface grew.

Gate: :app:testDebugUnitTest --tests GeneratedMediaTest BUILD SUCCESSFUL
(24 tests, 0 failures).

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: HAPI <noreply@hapi.run>
2026-11-02 18:09:49 +08:00
..

HAPI Android

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

End users: install HAPI from Google Play. This README covers development builds.

  • 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)

Current capabilities

The app provides sessions/chat, approvals and questions, new sessions, attachments, files/Git, Scratchlist, standard dictation, usage/storage, settings and encrypted-relay or direct-FCM notifications. Model and permission controls follow agent/session capabilities; usage/storage require the owner namespace. See the native app guide for platform differences, pairing and features currently available through the web.

  • Session actions: pin/archive plus Rename, Delete and explicit Reopen from the list/chat actions. Delete requires an inactive session. Resume or Reopen can return another session ID: seed its window, move the draft and replace navigation with the returned ID.
  • Composer: optimistic send, queue/steer where supported, drafts and attachments uploaded on selection. Slash suggestions combine session metadata and the slash-command RPC; the skills $ picker has no UI.
  • Incoming shares: Android Share → HAPI accepts single/multiple files, captions, text and links. Choose a session on the current or another paired hub; stopped sessions require explicit restore before upload. Content is added to the existing composer for manual sending. Private, no-backup staging survives pairing and process recreation until handoff, with a 24-hour pending-content lifetime. Once imported, attachments follow the ordinary composer lifecycle (they are not persisted as attachment drafts).
  • Dictation: provider discovery on chat entry picks the first standard-capable provider. The microphone stays hidden until one is available. First use requests RECORD_AUDIO; MediaRecorder records m4a/AAC, multipart transcription inserts text into the draft without sending. DictationController is tested with recorder/API fakes.
  • Background actions: FCM permission Allow/Deny and inline Reply enqueue WorkManager jobs. Workers resolve paired hubs using stored credentials, without requiring a foreground HubGraph. Firebase-free builds skip push.

Building

Native chat scrolling architecture and acceptance checklist: Native transcript scrolling.

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 :core:data:testDebugUnitTest :app:testDebugUnitTest
./gradlew :app:assembleDebug :app:lintDebug
./gradlew :app:installDebug                  # connected device
./gradlew :app:connectedDebugAndroidTest     # emulator/device instrumentation

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 protocol/data/app unit tests, :app:assembleDebug, :app:lintDebug, and Compose instrumentation on API 29 and API 36 for PRs touching android/** or shared/fixtures/**.

Protocol conformance fixtures

:core:protocol is the porting target for web/src/chat/ and is verified against golden fixtures generated from the web implementation. 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 read System.getProperty("hapi.fixtures.dir") from the checked-in golden fixture set. CI re-runs this suite whenever android/** or shared/fixtures/** change.

Fixtures pin protocol/state semantics, not identical web/native presentation. When changing fixture inputs or generation, run bun run gen:fixtures at the repo root and include generated changes; never hand-edit fixtures. See the fixture guidance.

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, requiring the supported protocol version exactly), 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 → Hubs and settings → Sign out) deletes the stored credentials for that hub and drops it from the roster.

Temporary authentication refresh failures (network/5xx) keep paired credentials. A rejected access token or a second 401 after a successful refresh requires re-pairing. See the auth contract.

Firebase / push

Official Firebase builds: pair an updated hub and allow notifications. The official Firebase client configuration is bundled in the app. Hubs without a private FCM_SERVICE_ACCOUNT_PATH use the official push relay automatically; users do not create Firebase projects or configure service-account keys. Google Play services and FCM connectivity are needed. The push relay also works with hubs accessed through Tailscale or other HTTPS setups; it is independent of hapi hub --relay.

Private builds: create a Firebase project for your application ID, download google-services.json into android/app/, and configure the hub's FCM_SERVICE_ACCOUNT_PATH for that same project. Existing configured hubs keep direct delivery under the default HAPI_ANDROID_PUSH=auto. Use relay, fcm, or off to select explicitly. HAPI_PUSH_RELAY_URL overrides the shared Android/iOS relay URL. One hub cannot mix private-project builds and official-project builds.

Builds without Firebase: the Google services plugin stays conditional. Without app/google-services.json, Firebase does not initialize and push paths no-op; ordinary PR builds require no credentials. Official builds use a separate mandatory configuration check (below).

Encrypted relay: the app registers its FCM token, install ID and random 32-byte pushKey with every paired hub. The ID/key are persisted together in Keystore-encrypted preferences excluded from cloud backup and device transfer. First upgrade replaces the old DataStore identity through the hub's token deduplication; later token rotations reuse the ID/key. On start, pairing, token rotation and worker retries, registrations refresh automatically. Sign-out unregisters before wiping that hub's credentials.

Relay messages carry only hapi_v/hapi_e. AES-256-GCM decryption uses the same golden vector as iOS; notification content is unavailable to the relay and Google. Failed decrypts are dropped. Direct private FCM retains its existing unwrapped data payload. Rendering, foreground-chat suppression, Allow/Deny and Reply workers use the same decoded PushPayload in both paths. input-request notifications instead preview the first question, remaining question count and session name, using the HIGH-importance input_requests channel. They offer no Allow/Deny or message Reply: tap to open the session and answer in the question form. The notification contract still does not name the sending hub: action workers try the active hub first, then other paired hubs on session miss; tapping opens the session against the active hub.

See native companion contract for wire details and relay deployment for maintainer setup.

Official APK/AAB builds

The Android Official Build workflow is manually dispatched on main. It tests and uploads signed APK/AAB workflow artifacts. It does not publish to Google Play itself; the maintainer uploads the AAB to the Play Console. Provide a new positive version_code and the desired version_name.

Repository configuration, set once by the maintainer:

Setting Value
Variable ANDROID_FIREBASE_PROJECT_ID Project used by the deployed relay's FCM service account
Secret ANDROID_GOOGLE_SERVICES_JSON Firebase client config for run.hapi.companion in that project
Secret HAPI_UPLOAD_KEYSTORE_BASE64 Base64-encoded release keystore
Secret HAPI_UPLOAD_KEYSTORE_PASSWORD Keystore password
Secret HAPI_UPLOAD_KEY_ALIAS Optional, defaults to upload
Secret HAPI_UPLOAD_KEY_PASSWORD Optional, defaults to store password

-PhapiOfficialBuild=true -PhapiFirebaseProjectId=<project> requires a matching Firebase project/package and release signing configuration. Missing or mismatched settings fail the build instead of shipping a package without working push. hapiVersionCode and hapiVersionName override the normal version defaults. The Firebase service-account private key belongs only on the relay and must never enter Android build artifacts.

Roll out relay support/credentials first, then the hub, then the app. Verify real background, lock-screen and cold-process notifications using a hub with no Firebase settings; test Allow/Deny/Reply and an iOS push before publishing.

Release signing

Builds do not require signing secrets. :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).

Modules

Module Type Responsibility
:core:protocol pure Kotlin/JVM (no Android) Hub wire types, chat pipeline, message pagination, versioned patches, agent/mode catalogs, git parsers, pairing links, and golden-fixture conformance tests.
:core:data Android library OkHttp API/SSE transport, per-hub authentication, secure credentials, StateFlow stores and disk snapshots, encrypted push registration/decoding, and background notification actions.
:app Android application Compose screens for pairing, sessions/chat, approvals, new sessions, files, Scratchlist, dictation, usage/storage, and settings; navigation, localization, FCM service, and WorkManager wiring.

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

Internationalization

The app ships English (default) and Simplified Chinese (app/src/main/res/values-zh-rCN/strings.xml). App UI strings live 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.

Tool previews

Ordinary chat tools stay compact summaries. Tap a tool or tool group to open a native Navigation Compose page; Back returns one level, Close returns to the chat. Groups start at their latest tool, retain their position when returning from details, and never follow streaming updates automatically. The Latest tool toolbar action scrolls explicitly. Agent processes have their own page with lazy child rows. Approvals remain in the conversation or a live process page; ordinary tool detail pages are read-only.

Plan proposals (ExitPlanMode / exit_plan_mode) start fully expanded in the conversation, rendering the complete input.plan Markdown before approval controls. Tapping the header folds the card. Plan documents are prewarmed in the chat Markdown cache and do not use the ordinary tool-output paging budget; raw input/result remains under Source. Shared Codex proposals also show Implement plan and Continue planning when the active session's agentState.codexPlanProposalId matches the tool-call id. Implementation uses the dedicated plan endpoint, not permission approval; continue hides that proposal’s action menu locally and focuses the composer, preserving its draft and plan mode without sending a message. The plan document remains readable, and a new proposal gets a fresh menu. The menu stays visible when the document is folded; pending/error state survives row recycling. Withdrawn, historical and child proposals remain read-only (an outstanding operation/error can still be shown).

Details recognize namespaced command/script/patch calls, unwrap common nested result envelopes, and keep command exit/status metadata visible. File reads use source-language highlighting; web/agent prose uses Markdown. Source reveals the original input/result, including fields omitted from the preview. Mixed text/media results stay JSON instead of dropping non-text blocks.

Question details show recorded selections, custom answers and notes with Markdown questions/options. Codex choice questions with isOther: true add None of the above and focus optional notes when selected; empty notes are valid. Translations never change the submitted None of the above wire value. Pi/MCP forms without isOther keep their existing choices. Recorded other answers and notes are also shown in question details. request_user_input also restores answers from historical results; live permission answers take precedence. Answered cards avoid duplicate results, but retain errors and the full input/result/answers under Source. Pending questions are answered in the conversation.

Tool inputs/outputs display one part at a time, up to 20,000 Unicode graphemes or 400 source lines. Previous/Next replace the mounted part; visited parts do not accumulate. Large diffs/Markdown use paged source. JSON formatting and output preparation run off the UI thread. File mutation details also offer View current file, distinct from the recorded tool input/result.

Inspectors share the conversation's pipeline and SSE subscription. Opening one freezes tail following, cancels hidden history loading and recording, and hides the keyboard. Returning retains the transcript anchor. Trimmed selections remain readable as labeled snapshots; an epoch reset closes obsolete inspectors.

Long messages and reading layout

User prompts remain inline through 8,000 graphemes and 120 source lines. Larger prompts show a 2,000-grapheme / 24-line preview and View full message opens a reader with one 4,000-grapheme / 80-line part mounted. Pagination preserves whitespace, CRLF, emoji and combining sequences exactly; character counts refer to Unicode graphemes, not UTF-16 offsets.

Copy full content uses the clipboard up to 64 Ki UTF-16 code units. Larger content, or a failed clipboard operation, offers UTF-8 file export through FileProvider and Android's sharesheet; Binder receives a URI, not the text. Exports older than 24 hours are cleaned on the next export.

Body/composer/user text uses 16sp/24sp, code/diff/terminal 14sp/20sp, captions 12sp/16sp. Content and composer share a centered 720dp reading column with 16dp minimum side margins. Bubble widths use actual container constraints, including split-screen; Android font scaling remains enabled.

Connection status and home filters

The chat subtitle reserves its height. Reconnecting · Tap to retry appears after four continuous foreground seconds of outage; retry/backoff transitions do not restart that grace period. Transport state is separate from message events. Default-network/interface/route changes wake reconnect immediately, preserving replay cursors; background retries defer until foreground. A local hub route does not need Android's internet-validation capability.

Home centers the Sessions title between a Hubs and settings icon and a Filters icon, keeping the new-session FAB. The hub menu is the only entry for switching/adding hubs, app settings and sign-out; the active hub is checked. The filter icon marks an applied filter and opens a Material 3 single-selection sheet. Choices come from all sessions, including historical machines; names/IDs determine ordering, never counts. Duplicate names include IDs; unnamed and unknown machines are labeled. The applied filter has a Clear action, is transient per home/hub, and is cleared when no longer valid. The home holder follows the active connection instance, releasing the previous store and filter even when switching back to a previously used hub URL.

Pairing and Settings both link to the privacy policy. These controls and notices ship in English and Simplified Chinese.

Validation status (2026-09-12)

Protocol/data/app JVM suites: 722 tests passed. Debug APK, instrumentation APK and lint passed. Pixel 6 (Android 17/API 37): installed and visually checked the home toolbar, hub/settings menu, applying/clearing filters, English/Chinese switching and code-copy feedback. Code headers keep an 18dp action icon inside a 48dp touch target, at the trailing edge even with short language labels.

API 36 ARM64 emulator (macOS Hypervisor.Framework): 8 targeted instrumentation tests passed, including 5 new toolbar/code-layout regressions (320dp width, English/Chinese, 2× font scaling), exact clipboard/file export and question details. The full 30-test run was not all green: 3 transcript group-anchor checks and the tool-browser initial-position check also fail with the pre-fix APK. One question-details timeout passed on targeted rerun; the opt-in frame probe was skipped. API 37 instrumentation is blocked by the current Espresso dependency calling the removed InputManager.getInstance() method.

Earlier API 29 checks passed 19 chat/reader regressions. No 60/120 Hz device frame-time, memory, or predictive-back measurements are claimed by these checks.

Unread state uses the hub’s revision read-state contract. List selection and metadata updates never acknowledge reading. The resumed chat acknowledges only its rendered tail, with a durable outbox scoped by hub and namespace.