Operational guide for coding agents working in Heron. The README is the deep, human-facing reference for architecture and build/publishing; this file is the terse map of where things live, the pattern to copy, the commands to run, and the rules to respect. When something here needs more depth, it links into the README rather than restating it.
Heron is an adaptive, reactive, offline-first Bluesky/ATProto client built with Kotlin Multiplatform and Jetpack Compose. It targets Android, iOS, and Desktop (JVM) — there is no web/wasm target. See README · Architecture for the full picture.
Five kinds of modules, in a layered dependency graph: data → ui → ui-scaffold → feature → composeApp
(arrows point from a layer to what's built on top of it; compile-time deps run the opposite way).
The DI graph mirrors these layers — see README · Dependency Injection.
| Kind | Modules | Role |
|---|---|---|
data:* |
data/core, data/models, data/database, data/lexicons (generated), … |
Root layer. Repositories, Room, Ktor/Ozone ATProto, WriteQueue. Depends on no other layer. Reads never error; writes are queued. |
ui:* |
ui/core, ui/media, ui/timeline, ui/tiling, ui/profile |
Reusable Compose components/effects. ui/timeline holds TimelineState/timelineStateHolder (paging via Tiler). |
ui/scaffold |
ui/scaffold, ui/sheets |
ui/scaffold (formerly scaffold) holds AppState, navigation, pane coordination, back previews, and exposes PaneScaffoldState to features. ui/sheets sits just above it with the app's bottom sheets, which depend on ui/scaffold so they self-navigate. |
feature:* |
~23 destinations: feature/feed, feature/profile, feature/home, … |
Navigation destinations (screens). feature/template is the shared abstraction they depend on — not a copy-me scaffold. |
| app modules | composeApp, androidApp, desktopApp |
composeApp = shared assembly + per-platform EntryPoint*.kt. androidApp/desktopApp are OS launchers; iOS launches from the iosApp Xcode project. |
Every feature:* module has the same four parts. feature/feed is the reference example
(feature/feed/src/commonMain/kotlin/com/tunjid/heron/feed/):
State.kt— aninterface Statewith an immutable@Snapshottable/@Serializableimplementation, plus a sealedAction(val key: String)hierarchy.<Name>ViewModel.kt— an@AssistedInjectViewModel (assisted args carry the navigation route) that delegates toscope.actionSuspendingStateMutator(...). Mutation handlers live in theproducerlambda; state is produced withstarted = SharingStarted.WhileSubscribed(FeatureWhileSubscribed).<Name>Screen.kt— a composable takingstate,actions: (Action) -> Unit, andPaneScaffoldState.di/Bindings.kt— a@BindingContainerexposing two binding sets:<Name>Bindingscontributing thePaneEntryscreen factory (@IntoMap) into the app'sentryMap, and<Name>NavigationBindingscontributing aRouteMatcher(@IntoMap, viaurlRouteMatcher) into therouteMatcherMap.
See README · State production for the Action-key parallelism model and the Mutator pipeline.
Metro builds and verifies the DI graph at compile time, so a missed wiring point is a build error with a clear message — building the feature is the test. The four places to touch:
- Create
feature/<name>/with abuild.gradle.ktsapplyingid("kotlin-library-convention")plus the Compose and serialization plugins (copyfeature/template/build.gradle.kts), and the four source files above. - Register the module in
settings.gradle.kts— add":feature:<name>"to theinclude(...)block (alongside":feature:feed"). - Add the bindings to the graph:
@Includes <name>BindingsinAppGraphand@Includes <name>NavigationBindingsinAppNavigationGraph. - Instantiate both in the graph-factory call in
EntryPoint.kt(the<name>Bindings = <Name>Bindings(...)list).
Use the Gradle wrapper (./gradlew). Dependencies are versioned in
gradle/libs.versions.toml — add via the version catalog, never
hardcoded coordinates.
Formatting rules live in .editorconfig (4-space Kotlin indent, ktlint
intellij_idea style, trailing commas allowed). spotlessApply runs ktlint against these settings,
so honour them — don't reformat against a different convention.
| Task | Command |
|---|---|
| Format (run before committing) | ./gradlew spotlessApply — ktlint via Spotless, applied to all modules |
| Format check only | ./gradlew spotlessCheck |
| Data-layer tests | ./gradlew testDataLayer — aggregates allTests for every :data:* module |
| Single module's tests | ./gradlew :feature:feed:allTests (or :data:core:testDebugUnitTest) |
| Android debug build | ./gradlew :androidApp:assembleDebug (or :androidApp:installDebug) |
| Desktop run | ./gradlew :desktopApp:run |
| Desktop package (macOS) | ./gradlew packageReleaseDmg |
iOS is built from the iosApp Xcode project against the composeApp framework — not a pure Gradle
run. See README · Building.
- State is immutable +
@Snapshottable. Mutate only through the mutator'sproducer; never hold mutable state in a composable or ViewModel field. Action.keycontrols parallelism. Same key → processed sequentially; different keys → run in parallel. Navigation actions share a key; pagination actions share a key (Tiler).- Reads never error; all writes go through the
WriteQueue. Enqueue viawriteQueue.enqueue(Writable.*)(offline-first, persisted, retried) — don't call the network directly from a feature. - Lifecycle-aware collection uses
SharingStarted.WhileSubscribed(FeatureWhileSubscribed); coroutines stop ~2s after the displaying component pauses. - New
.ktfiles need the Apache 2.0 licence header (Spotless/ktlint enforces it — copy it from any existing file). - One parameter per line for any function/constructor that takes arguments — declarations and
call sites both. The
.editorconfigalready forces this for signatures (force_multiline_when_parameter_count_greater_or_equal_than = 1); match it at call sites too. - Use named arguments when invoking a method that accepts them — favour readability over positional brevity.
- Keep shared code in
commonMain; only put platform-specific code inandroidMain/iosMain/desktopMain.
data/lexiconsATProto bindings are generated by the Ozone lexicon plugin (ozoneLexiconGenerator) — regenerate, don't edit by hand.- iOS signing &
iosApp.xcodeproj/project.pbxprojpatching,aps-environmentflipping, and the Kotlin/Native devirtualization compiler flag are deliberate CI/build concerns documented in README · iOS publishing notes — don't "fix" them casually. **/build/**output and.hprofheap dumps are not source.
- Deep dives: Architecture · Dependency Injection · Navigation · State production · Building
- Upstream libraries that shape the code: Metro (DI) · Mutator (state) · Tiler (paging) · treeNav (navigation) · Ozone (ATProto) · Composables (UX).