Heron is a Compose client for Bluesky built around a compile time DI graph
A graceful multiplatform bluesky client
At a glance
- What is it?
- Heron is a Kotlin Multiplatform Bluesky client for Android, iOS and desktop, organised into data, UI, scaffold, sheet and feature modules, with a Metro dependency graph assembled at build time and an offline-first data layer whose writes go through a queue.
- Who is it for?
- Heron suits developers who want a cross-platform Bluesky client built on Compose Multiplatform and want to study a real multi-module architecture rather than a single app, since the module boundaries and the binding types are documented in enough detail to follow.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 1 day ago.
- What is it written in?
- Mainly Kotlin, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.
Editorial analysis
An offline-first Bluesky client with adaptive layout
Heron describes itself as a Jetpack Compose adaptive, reactive and offline-first Bluesky client. Three claims in that sentence are worth separating, because they point at different parts of the design. Adaptive is the layout side, where panes and multipane arrangements adapt to the device screen size, and the feature modules are explicitly built to run side by side in panes depending on the navigation configuration and the screen size. Reactive is the state side, handled with coroutines and Kotlin flows. Offline-first is the data side, and it is the one with the strictest rule in the codebase.
The visual side is built on Material design and motion, heavily inspired by the Crane material study, and the repository includes animated gifs of the scroll animations, screen transitions and thread diving, which tells you that motion is treated as a feature rather than an afterthought.
The README also lists the libraries doing the drawing, with the Jetpack shapes graphics library for geometric shapes and the author's own composables library for interaction patterns such as pinch to zoom, drag to dismiss, collapsing headers and multipane layouts.
The distribution story is Android, and the rest is up to you
The download section is short and it defines the audience. There is a Google Play badge for the app under the package com.tunjid.heron, and an Obtainium badge that points at the GitHub releases rather than at a store, which is the usual route for people who want a specific build of an Android app without a store account.
That combination is a deliberate answer to the app distribution problem for a multiplatform project. Heron is built for Android, iOS and desktop, and the README describes the multiplatform targets, but the only end-user install path advertised is Android. A desktop build can be produced from the repository, since composeApp is the module that assembles the app and each platform has its own launcher entry point, and an iOS build follows the same path, but neither is offered as a download here.
The release naming is worth noting if you follow the project. The recent tags are 3.0.19, 3.0.18 and 3.0.17, and their release titles are Heron v172, Heron v170 and Heron v168, so the marketing version and the internal build number advance separately, with the internal counter running well ahead of the package version.
Five module kinds, and the dependency order they enforce
Heron is a multi-module Kotlin Multiplatform project targeting Android, iOS and desktop that follows the Android architecture guide, and the README says the architecture decisions are very similar to the Now in Android sample. The module taxonomy is the interesting part, because the naming prefixes are not decoration, they are the dependency rules.
There are five kinds. The data-* modules hold the models and the repository implementations for reading and writing data. The ui-* modules hold reusable Compose components and effects, from basic layout through to multimedia components for photos and video playback. The ui-scaffold module holds the application state and coordinates app level UI logic such as pane display, drag to dismiss and back previews, and it is the entry point to the multiplatform application. The feature-* modules are navigation destinations or screens. And composeApp is the module that assembles the fully wired app, depends on all other modules, and is consumed by each platform's launcher.
One module breaks the pattern on purpose. ui-sheets holds the app's bottom sheets but depends on ui-scaffold, which is unusual for a ui-* module, and the reason given is that the sheets need to drive their own navigation.
Data reads never fail, and writes go through a queue
The data layer carries a rule that shapes everything above it: data reads should never error, while writes are queued with a WriteQueue. That is the offline-first contract in one sentence, and it means the UI reads from the local database and treats the network as a synchronisation source rather than a requirement.
Persistence is split by kind. Jetpack Room on SQLite holds the app data, and because the app is described as highly relational, a class called MultipleEntitySaver is used to save Bluesky network models. Jetpack DataStore is used for blob storage of arbitrary data with protobufs, so binary or opaque payloads do not have to be squeezed into relational tables. Network traffic goes through Ktor using the Ozone at-proto bindings.
The consequence for a user of the app is that a timeline can be browsed with no connection and still reflect what was already fetched. The consequence for someone reading the code is that error handling in the data layer is not where you will find it, since reads are not supposed to fail, and the interesting failure handling lives in the write queue and the sync path.
Metro assembles the dependency graph at build time
Dependency injection uses the Metro library, which constructs the dependency graph at build time and is therefore compile time safe. That is the structural decision behind the module design, because a build time graph means the wiring of a feature module is checked by the compiler rather than at runtime.
The graph is layered, and the README describes it as conceptually rooted in the data layer with each level below built on the levels above, with the fully assembled composeApp at the leaf. The data level is the root and depends on no other layer, contributed to the graph through DataBindings. The UI level depends on data and can host ViewModels with access to it, contributed through SheetBindings into a map keyed by ViewModel type that AppState exposes through PaneScaffoldState. The scaffold level depends on data and UI, and is where navigation lives, contributed through ScaffoldBindings. Feature modules depend on data and scaffold and each contribute a route matcher binding and a screen binding.
The last level, composeApp, is where AppGraph includes every layer's bindings and exposes the assembled entry map and app state. The README's diagram of the graph is a mermaid block, and the first line of it in the source is simply the graph declaration:
gThe rest of the diagram is not reproduced in the text, so the binding names in the prose are the authoritative description.
AppState, PaneScaffoldState, and navigation that the screens own
The scaffold module holds the application state in the AppState class and coordinates app level UI logic, and three discrete states are managed inside it: IdentityState, NotificationState and NavigationState. They are coordinated by AppState, which holds the app level concerns, and PaneScaffoldState is a slice into AppState that feature modules can see.
That slice is the mechanism that lets a feature behave like a first class part of the app rather than a screen bolted onto it. A feature ViewModel has access to the data layer and to navigation semantics, and it uses PaneScaffoldState to glean app level scope from the scaffold. The sheets work the same way: because ui-sheets can see PaneScaffoldState, the sheets navigate themselves rather than emitting events for someone else to handle.
Assisted injection is used for feature screens to pass navigation arguments to the feature. The pattern is consistent across the app: a feature declares what it needs, the scaffold provides the scope, and the compile time graph makes sure the combination exists.
Timeline data production is driven by a tiling library
The higher level aggregated abstractions live in the ui-* modules, and the most notable is ui-timeline, which holds the TimelineState and timelineStateHolder that drive timeline data production with the Tiling library. The author's Tiler project is the piece that makes a feed a sequence of pages that can be loaded, prefetched and recycled, which is the hard part of a social timeline in a reactive UI.
The layering is the interesting detail. ui-timeline sits above the plain components, and ui-sheets depends on both ui-scaffold and ui-timeline, so the bottom sheets and the feed are related concerns rather than independent ones. Elsewhere in the ui-* modules, reusable components and Compose effects cover the range from basic layout to multimedia components for displaying photos and for video playback, which is what an image and video heavy network needs.
Because these are separate modules with an explicit dependency on the timeline, a fork that changes how the feed is paged has to respect that edge, and a fork that only wants different bottom sheet behaviour does not have to touch the feed at all.
composeApp and the three platform entry points
The final piece is the assembly module. composeApp depends on every other module and holds several source sets, and the division inside it is straightforward. commonMain carries the code common to all targets, including the shared createAppState builder that wires the whole DI graph. The androidMain, desktopMain and iosMain folders hold code compiled only for that platform, mostly each platform's createAppState overload, and each platform's entry point creates the AppState and renders the app.
The mechanism is worth spelling out because it is the price of a shared graph with platform-specific entry points. The graph itself is shared, the wiring builder is shared, and only the leaf that starts the application differs per platform. A build pipeline, a launcher, an app icon and the platform SDK are what change between Android, desktop and iOS.
The repository reflects a real project around this code rather than a library, with an apps directory, a build-logic directory for convention plugins, a fastlane directory for release automation, a gradle wrapper, stability_config.conf, a docs directory with the images, and the usual repository files including an AGENTS.md and a LICENSE.txt. The licence is Apache-2.0, the project is not archived, and the last push was on 2026-10-19.
Editorial conclusion
Heron suits developers who want a cross-platform Bluesky client built on Compose Multiplatform and want to study a real multi-module architecture rather than a single app, since the module boundaries and the binding types are documented in enough detail to follow. It is not a good base for a quick client of your own without Kotlin multiplatform experience, and it is not a fit for a project that needs a published iOS App Store or desktop build, since the README only points at Google Play and Obtainium. Before you build on it, check three things: that the Ozone at-proto bindings and Ktor still match the Bluesky API version you need, that the write queue semantics suit your offline expectations, since data reads never error and writes are queued, and that the data, UI, scaffold and feature module boundaries are ones you can preserve rather than collapse. The licence is Apache-2.0, the latest release is 3.0.19 tagged Heron v172, and the last push was on 2026-10-19.
Frequently asked questions
Which platforms does the Heron Bluesky client run on?
Heron is a Kotlin Multiplatform project targeting Android, iOS and desktop, with composeApp assembling the app and each platform having its own entry point. The README advertises downloads for Google Play and Obtainium only.
How does Heron handle data when the network is unavailable?
The data layer rule is that data reads should never error, while writes are queued with a `WriteQueue`. Room persists the app data in SQLite, DataStore holds arbitrary protobuf blobs, and Ktor with the Ozone at-proto bindings handles the network side.
What does the Metro dependency injection setup do for Heron?
Metro constructs the dependency graph at build time, which makes the wiring compile time safe. The graph is layered from the data modules at the root down to the assembled composeApp at the leaf, with each level contributing bindings such as DataBindings, SheetBindings and ScaffoldBindings.
How are the module types in Heron named and what do they do?
There are five kinds: `data-*` for the data layer, `ui-*` for reusable Compose components and effects, `ui-scaffold` for the app level state and navigation, `feature-*` for navigation destinations, and `composeApp` for the fully assembled app. The `ui-sheets` module is the exception, since it depends on `ui-scaffold` so the sheets can drive their own navigation.
What is the licence and release cadence of the Heron client?
The project is licensed under Apache-2.0 and is not archived, with the last push on 2026-10-19. Recent releases are 3.0.19, 3.0.18 and 3.0.17, titled Heron v172, Heron v170 and Heron v168, so the package version and the internal build number advance separately.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/tunjid-heron)Was this useful?