An Android reader for the Linux kernel mailing lists, backed by the public-inbox archives at lore.kernel.org. Kotlin, Jetpack Compose, Material 3, offline-first.
Built for the way kernel mail is actually read: threads are trees, most messages are patches, and the interesting ones are long.
Licence: GPL v3, or a commercial licence — see Licence.
The endpoint contract below was established by probing the live service, not from documentation. It is the foundation the data layer is built on.
| Purpose | Endpoint | Format |
|---|---|---|
| List catalogue | /manifest.js.gz |
gzipped JSON, ~400 git epochs → 353 lists |
| Thread feed | /<list>/, next page /<list>/?t=<cursor> |
HTML topic list, ~50–100 threads / 36 KB |
| Search | /<list>/?q=<query>&o=<offset> |
HTML, 200 hits per page |
| Full thread | /<list>/<root-message-id>/t.mbox.gz |
gzipped mboxrd |
| Single message | /<list>/<message-id>/raw |
RFC 5322 |
| Newest messages | /<list>/new.atom |
Atom, 25 entries |
<list> is any slug from the manifest (lkml, netdev, linux-staging, …) plus the
virtual aggregate all, which is browsable but absent from the manifest and so is
injected by hand.
/all/ looks like it should be the one place to fetch any thread from, and it is not.
Measured against live data, the newest thread on 4 of 9 sampled lists returned 404
from /all/ while resolving fine on its own list — the aggregate index catches up behind
the per-list ones. But when /all/ does have a thread it returns a richer result,
unioning in replies that only went to a cross-posted list (one lkml thread: 172 KB via
/all/ against 53 KB via /lkml/).
So each thread records the list it was discovered in and is fetched from there, with
/all/ as a fallback for threads whose origin is unknown (search hits) or which that list
no longer carries. Only a 404 advances to the fallback; other failures propagate, since
retrying a timeout against a second path just doubles the wait.
lore.kernel.org sits behind Anubis bot protection plus an nginx UA filter, and the two disagree about who counts as a bot:
| Client UA | Result |
|---|---|
contains Mozilla |
Anubis JavaScript proof-of-work page, served as 200 OK text/html |
curl/... |
403 from nginx |
kernelfeed/1.0 (Android; ...) |
served normally on every endpoint |
The failure mode matters more than the rule. A browser-shaped UA does not get an error — it gets a successful response containing an interstitial, which then fails deep inside the mbox or topic parser as a confusing "parse error". So:
UserAgentInterceptorpins a descriptive, contactable, non-browser UA.BotChallengeInterceptorpeeks at HTML responses and converts an interstitial into a typedAppError.BotChallenge, so the diagnosis points at the request, not the data.
The app does not attempt to defeat the bot protection: identifying honestly is both what makes it work and what public-inbox asks of automated clients.
Two other sharp edges, both encoded in tests:
- The plain index ignores
?o=(it returns page 1 again). Only the?t=cursor pages it;?o=is for search. - Atom
<id>elements areurn:uuid:values, not Message-IDs. The Message-ID has to be recovered from the<link href>path segment.
ui/ Compose screens, ViewModels, AnnotatedString rendering
↓ (uses)
domain/ Models, parsers (diff, body, thread tree), use cases, repository interface
↑ (implements)
data/ Room + Retrofit/OkHttp + mbox ingest
The app opens on the list catalogue, not on a single hardcoded feed:
Lists (catalogue) → feed/<slug> → thread/<message-id>
Search ────────────────────────────↗
Saved ────────────────────────────↗
The catalogue is ordered pinned → curated → most recently active, with search over all 353 lists. Ordering alphabetically would bury the dozen lists anyone actually opens under ~340 narrow subsystem and CI archives.
Feed membership is a join table (feed_entries), not a column on the thread. The same
thread genuinely appears in several lists at once — a networking patch is on netdev,
lkml and all — and a single feedList column would make those lists fight over the
row, with whichever refreshed last winning and the others losing the thread entirely.
domain has no Android dependencies apart from the parsers' complete absence of them,
which is why the diff engine, body segmenter and tree builder are all testable as plain
JVM code.
The single most important structural decision: Flows are backed by the database, never
by the network. Network calls are one-shot suspend functions whose only job is to
write into Room; the UI updates because the database changed.
fun observeFeed(listSlug: String): Flow<List<ThreadSummary>> // Room
suspend fun refreshFeed(listSlug: String): Resource<Unit> // network → RoomConsequences that fall out for free:
- A cold start paints the last-seen feed immediately — there is no spinner-first state.
- No code path exists where an unreachable network yields a blank screen.
- A thread being streamed in from its mbox re-renders progressively as batches land, with no progress plumbing: Room re-emits, the tree rebuilds, the list grows.
Resource<T> carries Loading / Success / Error / Offline. Offline is a
first-class outcome, not an error — "here is your cached data and we could not refresh it"
should be a banner over real content, never an error screen.
Opening a thread fetches t.mbox.gz once: a complete 14-message thread is about 9 KB
compressed. Fetching messages individually would mean 14 round trips for the same bytes,
and on mobile latency the round trips dominate.
It also makes offline trivial — the thing already downloaded is the offline artefact. Saving a thread pins it against eviction rather than triggering a separate download.
isSaved (user intent) and isCached (cache fact) are separate columns. Eviction only
ever touches isCached && !isSaved, which is what makes the offline promise
unconditional.
Three separate lifetimes govern a thread, and conflating them would be a mistake:
| Constant | Value | Meaning |
|---|---|---|
READ_CACHE_TTL_MILLIS |
24 h | how long an opened thread is served without a refetch |
SAVED_REFRESH_TTL_MILLIS |
6 h | when background sync considers a saved thread stale |
EVICT_AFTER_MILLIS |
14 days | when an unsaved cached thread is discarded |
Note that none of these control how fast a thread reopens. Because the thread screen observes Room, cached messages paint immediately regardless; the read TTL only decides whether a network request is also worth making.
Feed refreshes use insert-then-patch rather than REPLACE, so server-owned columns never
clobber client-owned ones — a plain upsert would silently un-save the user's threads on
every refresh.
A mail body is not a well-formed patch file. The patch is embedded in prose, may be
truncated mid-hunk, and the surrounding commentary is full of lines starting with -.
DiffParser therefore never infers where a hunk ends from line prefixes. It reads the
@@ -a,b +c,d @@ counts and consumes exactly that many old/new lines:
@@ -1,2 +1,2 @@
-old
+new
ctx
- this is prose, not a deletion ← counts exhausted; hunk ends here
Precedence in BodyParser is deliberate, and quotes outrank diffs: a quoted patch
(> +foo) is material being discussed, not a patch to apply, and rendering it as a live
diff would be wrong.
Real data is messier than the RFC implies. In-Reply-To is trusted first, then the last
resolvable References entry (nearest ancestor), because mailers truncate References
and some drop In-Reply-To entirely. A message whose parent is missing becomes an extra
root instead of vanishing, and reference cycles are broken rather than hung on.
lore serves the mboxrd variant, where body lines matching >*From were escaped with
one extra >. Not unescaping corrupts quoted text — and since From commonly opens a
quoted reply, it corrupts reply structure too.
Parsing is line-by-line with one message in memory at a time, written to Room in batches of 40, so a 2,000-message thread costs the same peak memory as a 2-message one. Apache Mime4J handles RFC 2047 encoded-word headers, quoted-printable/base64, per-part charsets and nested MIME — the parts where hand-rolled mail parsers break on non-English names.
No parsing happens in a composable. Bodies are segmented into BodyBlocks by a use case
on Dispatchers.Default, and diffs are rendered into AnnotatedString off the main thread
via produceState, keyed on file + palette so the result survives recomposition and
scrolling.
A diff file becomes exactly two AnnotatedStrings — gutter and code. Emitting one
Text per line would mean 4,000 text nodes for a large patch, which no amount of lazy
layout makes cheap; as two strings it is two nodes and one layout pass. They are separate
strings so the line-number gutter stays pinned while long lines pan horizontally.
Rows are padded to a common width so the added/removed backgrounds form unbroken bars rather than stopping ragged at each line's last character.
Diff colours sit outside the Material dynamic palette. Added/removed carry semantics every developer already knows; re-tinting them from a wallpaper-derived scheme would render a green-on-red patch as lavender-on-mauve. Material dynamic colour governs the app chrome; the diff keeps a fixed, high-contrast palette in both light and dark.
Other UI decisions worth naming:
- Message content is monospaced and not re-wrapped. Kernel mail is hard-wrapped at 72–80 columns and full of aligned ASCII — tables, register maps, stack traces. Reflowing destroys that; over-wide lines are reachable by panning.
- Tree indentation caps at 8 levels (threads nest 15+ deep); past the cap depth is shown numerically so the deepest replies stay readable.
- Collapsing a subtree removes its descendants from the flattened list rather than hiding them, so collapsing 300 replies actually removes 300 items.
LazyColumnkeys are Message-IDs, so each card's internal state (expanded diffs, quote toggles) survives collapse and reflow.
SyncWorker runs every 6 hours on unmetered networks only — a saved patch series can
be several megabytes. It refreshes stale saved threads, then evicts unsaved cached threads
older than 14 days. Saved threads are never eligible for eviction.
It deliberately does not refresh the feed index: that is cheap to fetch on open, and doing it in the background would spend battery and the archive's bandwidth on a list the user may never look at.
export ANDROID_HOME=$HOME/Android/Sdk # or set sdk.dir in local.properties
./gradlew assembleDebug
./gradlew testDebugUnitTestRequires JDK 17. Gradle's configuration cache is disabled because KSP's task state is not serialisable against it.
36 unit tests, run against real captured responses from lore.kernel.org
(app/src/test/resources/) rather than synthetic fixtures — hand-written samples tend to
encode the parser's own assumptions, while production data carries the details that
actually break parsers: public-inbox's line-broken href attributes, mboxrd escaping,
DKIM headers longer than most parsers' default limits.
There is also an opt-in live smoke test that exercises the whole pipeline against the real archive:
./gradlew testDebugUnitTest -Dkernelfeed.live=true --tests '*LiveSmokeTest*'It is excluded by default: it depends on a third-party host being up, so a CI failure there would say nothing about the code. Run it when a parser changes or a lore-side change is suspected, to confirm the captured fixtures still match what the archive serves.
List catalogue, per-list feeds, search, thread tree, patch rendering, offline saving and
background sync are implemented and build clean (assembleDebug, assembleRelease with
R8, lintDebug with zero errors). The full pipeline has been verified end-to-end against
production lore.kernel.org, across all, lkml, linux-staging, netdev and bpf.
Not yet built: reply/compose (the archive is read-only; sending would need SMTP), and instrumented UI tests. The app has not been run on a device — there is no emulator in this environment, so the Compose layer is verified by compilation and lint only.
Copyright © 2026 Luka Gejak.
Dual-licensed. Use it under the GNU General Public License v3 (LICENSE),
or under a commercial licence if the GPL's obligations don't suit you — for closed-source
products, unpublished modifications, or distribution channels whose terms conflict with the
GPL.
For commercial licensing, contact lukagejak5@gmail.com.
LICENSING.md explains both options, and lists the third-party dependency
licences (all Apache 2.0, plus one GPL v2 + Classpath Exception — which is why GPL v3
was chosen: Apache 2.0 is incompatible with GPL v2).
Contributions are covered by a copyright-assignment agreement — see
CONTRIBUTING.md before sending a patch.
Archived mail read by this app belongs to its respective authors and is not covered by this licence.
Linux is a registered trademark of Linus Torvalds. KernelFeed is an independent project and is not affiliated with, endorsed by, or sponsored by the Linux Foundation, the Linux kernel project, or kernel.org. References to the Linux kernel and to the mailing lists archived at lore.kernel.org are descriptive, identifying the material this reader displays.