Skip to content

For Developers

SorGuayteawLord edited this page Aug 17, 2026 · 1 revision

An orientation page. The authoritative documents are in the repository: docs/ARCHITECTURE.md, docs/PROTOCOL.md, CONTRIBUTING.md.

Toolchain

Android Studio Koala or newer
JDK 17+
Android SDK 35 (compileSdk / targetSdk 35, minSdk 26)
Gradle 8.11.1 (wrapper) · AGP 8.7.3 · Kotlin 2.0.21
Stack Jetpack Compose · Hilt · OkHttp · kotlinx.serialization · DataStore · WorkManager

Dependencies are pinned in gradle/libs.versions.toml. Do not set org.gradle.java.home in gradle.properties — a machine path there breaks CI.

Modules

core/           pure JVM, no Android imports
  wire/         envelopes, the lenient WireJson codec, OkHttp transport,
                downlink-only WebSockets, the typed API client, the
                connection loop (readiness handshake + backoff)
  wire/dto/     kotlinx.serialization ports of the harness schemas
  session/      EventFold: raw session events -> ConversationSnapshot
  notify/       CompletionClassifier: which events deserve a notification
app/            Android UI, connection/discovery, foreground service,
                notifications, i18n
mock-harness/   Ktor stand-in for the harness /api server, used by tests
tools/capture/  Node recorder: real harness traffic -> conformance fixtures
tools/brand/    SVG -> VectorDrawable converter for the launcher icon
harness/        the LAN patch and setup guide for the harness itself

Two shapes worth internalising before changing anything:

  • The wire layer never crashes on unknown data. Unknown keys are ignored; unknown event, frame and tool-card types fall through to Unknown* passthroughs. HTTP status is carrier-only — business failures arrive as ok: false with a typed error code.
  • Session projections are derived, not fetched. Permissions, stats, token usage, context pressure and breakdown, image limits and plan state are read off the projection frames already in the snapshot, so they stay in lockstep with the transcript and cost no round trips. An absent key means the harness composes no such service, so the UI hides the control.

One process-scoped store owns all harness state and exposes it as StateFlows; screens observe, and user actions go back through the store to the API client. RPC errors never throw — they land on the store's error channel.

Build

./gradlew :app:assembleDebug      # debug APK
./gradlew :app:assembleRelease    # release APK (signed when keystore env is set)

The shipped version comes from the git tag: the release workflow exports DSH_VERSION_NAME from the tag name and versionCode is derived from it. A local build falls back to the literal in app/build.gradle.kts.

Release signing activates only when the keystore file exists, so a build without it produces an unsigned APK rather than failing:

Env var
DSH_KEYSTORE path to the keystore
DSH_KEYSTORE_PASSWORD · DSH_KEY_ALIAS · DSH_KEY_PASSWORD credentials

Developing against a real harness

  1. dsh web on your computer (default port 3080).
  2. Device or emulator over USB: adb reverse tcp:3080 tcp:3080, then connect the app to 127.0.0.1:3080.
  3. Over Wi-Fi: apply the LAN patch — see LAN Mode.

Connecting over adb reverse makes the app a loopback client, which is the only way to exercise the surfaces the harness pins to loopback.

Developing without one

mock-harness is a scriptable Ktor implementation of the /api protocol: the two WebSockets, the unary and gateway POST paths, session export, and a replica of the trust fence that 403s a non-trusted Host before dispatch. It deliberately does not depend on core's DTOs, so the mock and the client cannot co-drift.

tools/capture records real harness traffic into fixtures (Node 18+):

npm install --prefix tools/capture
DSH_URL=http://192.168.1.20:3080 DSH_SECONDS=10 node tools/capture/capture.mjs

Run it from the repository root so capture-output/ lands where .gitignore expects it.

Tests

./gradlew :core:test :mock-harness:test :app:testDebugUnitTest   # what CI runs
./gradlew :app:lintDebug

Plain JUnit4 with kotlinx-coroutines-test; no Robolectric and no instrumented tests. That is why decision logic is deliberately extracted into free functions — slash adjudication, search derivation, node visibility, tool-row summaries, subnet checks, connect diagnosis, subagent tree building, history paging, landing-session policy, version comparison — each of which is unit-tested directly.

MissingTranslation and ImpliedQuantity are promoted to lint errors: that is what actually enforces the eleven-language claim, so adding a string means adding it everywhere.

Conventions

  • Kotlin official code style; one file per screen or component where sensible.
  • The wire layer parses leniently — unknown keys, events and tool cards must fall back, never crash.
  • UI strings live in values*/strings.xml only. Never hardcode text.
  • Pull requests need CI green: unit tests, lint, assemble.

CI and release

Workflow Trigger Does
ci.yml push to main, all PRs JDK 17 + Android SDK → unit tests → :app:lintDebug:app:assembleDebug → uploads the debug APK as an artifact
release.yml tags matching v* Restores the keystore from secrets → DSH_VERSION_NAME from the tag → release unit tests → :app:assembleReleaseSHA256SUMS.txt → publishes a GitHub release

Release secrets: RELEASE_KEYSTORE (base64), RELEASE_KEYSTORE_PASSWORD, RELEASE_KEY_ALIAS, RELEASE_KEY_PASSWORD. Tagging v0.3.2 ships 0.3.2; see Release Notes.

Protocol reference

Everything about envelopes, the two downlink streams, the gateway paths, projections, the handshake and the trust fence is in docs/PROTOCOL.md. The authoritative shapes live in the harness repository itself — this app documents only the subset it implements.

Clone this wiki locally