textlog
An unofficial mobile client for a small text-only community, built against an API that was read-only on purpose. The interesting work was deciding what not to route around, and then fixing it at the source instead.

- Role
- Everything: the client, the design port, the release pipeline
- Stack
- Flutter · Dart · Riverpod · go_router · server-sent events
- Ships as
- F-Droid, signed per-ABI Android APKs and a macOS build on GitHub Releases, plus the web demo
- Source
- github.com/Faultless/textlog_flutter — AGPL, open to contributions
- Built for
- A community I read every day, at no charge and for no return
- Upstream
- Wrote the server's write API and had it merged, rather than scraping around it
- Licence
- AGPL-3.0, matching the service it talks to
- Now at
- v0.7.2 — feeds, search, notifications, bookmarks, polls, quizzes, voice clips
- 01
Why I built it
textlog is a 280-character, text-only community: no images, no metrics, no algorithm. It runs fine in a phone browser, which is exactly why nobody had bothered building a client for it.
I read it every day, and reading it on a phone deserved to feel like reading on a phone — native scrolling, threads you can follow with a thumb, new posts arriving on their own. There is no business here. It is free, open source, and made for a place I like being part of.
- 02
The constraint that shaped everything, and then moved
The public API was read-only: GET only, no authentication, no write endpoints of any kind. That was deliberate — the author had said he was wary of inviting automated posting — so the honest options were to respect it or to work around it.
Working around it was available. The site's own forms could be driven with a scraped session cookie. I didn't, and the reasoning is the point: an app built on someone else's unversioned HTML breaks the first time they change a template, and it takes a position the maintainer had explicitly not taken.
So for seven releases reading was fully native and every write handed off to textlog.cc in the system browser. That single decision removed authentication, token storage, an offline write queue and every sync conflict from the codebase. The constraint made the app smaller, not worse.
Then I stopped working around it and went and wrote it: bearer tokens, posting, editing, deleting, follow and report, added to the server itself and merged upstream. It ships with the guardrails that made the maintainer wary in the first place — accounts can still only be created in a browser, writes are rate limited per account, and sign-in codes are hashed and attempt-capped — because an API that is safe to open is the only kind worth asking for. The app now writes natively against endpoints that belong to everyone, not just to it.
- 03
The build
One idea carries most of the app: every scrollable list is a FeedSource — latest, hot, a profile, a hashtag, a post's replies. The server returns the same envelope for all five, so one notifier and one widget provide pagination, pull-to-refresh, empty states and error recovery everywhere. Adding a sixth feed is a class and one line; the compiler finds every site that must handle it.
Threads were the hard shape. The replies endpoint returns direct children only, so nesting costs a request per branching node. Every post does report its own reply count, which is what lets the app know where the branches are without paying to find out — and say exactly how many replies it chose not to load. Levels are fetched in parallel under a depth cap and a request budget, and anything past either shows as a counted link rather than quietly disappearing.
The visual identity is a port, not an interpretation. The palette is the site's CSS custom properties under their original names, the spacing scale is its own, and the body tokenizer reproduces its linkify function down to the rule that a full stop after a URL stays outside the link. A monospace face ships with the app so the three platforms agree. Four themes and a chosen accent sit behind one small icon, and optional markdown defaults to off — because the site renders plain text, and rendering it differently would show people formatting the author never wrote.
That tokenizer is why the app kept up as the site grew. Everything textlog added to a post body — code fences, LaTeX, redactions you press to reveal, polls, quizzes, link previews, voice clips, #todo checklists you tick from the post itself, #exec output under the code the server ran, #map places as a card that opens your maps app — arrived here as a case in one function rather than a feature in five screens.
The rest of the app is now the ordinary furniture a daily reader wants and a browser tab can't give: search across every note, notifications with a reply field and a mark-read button inside the notification, tabs you can reorder or switch off, and links back to textlog that open in the app rather than the browser. Feeds mark themselves read as posts scroll into view instead of waiting to be told, and drafts and bookmarks are the server's collections rather than the app's — so a note started on the website is here, and a post kept on the phone is on the website.
- 04
What was hard
The first release of replies was broken in a way that testing on my own machine never showed. Writes opened in an embedded WebView, which keeps a private cookie jar. textlog signs you in by emailing a magic link, that link opens in your default browser, and no platform hands an app the browser's cookies — so the session landed somewhere the app could never see. Replying was impossible for every user, permanently, and the app looked like it was working.
The fix was to stop embedding: writes opened a Chrome Custom Tab, which shares the browser's cookie jar, so the magic link landed in the same session the tab read. It kept the in-app feel, and it deleted a dependency. Reading the server's source settled a related temptation too — the magic-link token is consumed inside the transaction that creates the session, so an app that intercepted the link would claim the only session and leave the browser logged out, which was then the one place writing could happen. That ordering is why the server work came before native sign-in rather than after it.
Android had two more failures of the same kind: silent, and invisible until a real build. Flutter's template only grants INTERNET in the debug manifest, so a release APK reaches nothing; and Android 11 hides other apps unless they are declared, so url_launcher cannot resolve a browser and every reply does nothing at all. Both now get asserted against the built artefact before anything ships.
Getting onto F-Droid was its own kind of hard, because they don't take your word for a binary: they rebuild the tagged commit on their own machines and compare it against the one you published, byte for byte. That means the release build has to be reproducible — built on Linux x86_64, from the absolute path their builder uses, because package URIs and source paths are baked into the compiled artefacts. Signing has to add a signature block and touch nothing else, which the release script asserts rather than assumes. A test also fails if the build recipe drifts from the app's Flutter or NDK version, which is a great deal cheaper than waiting an hour for their buildserver to tell you the same thing.





What this means for your project
Most of the judgement here went into what the app refuses to do. A read-only API and a maintainer's stated intent are the kind of constraint that tempts you into scraping, and declining that is what kept the thing maintainable — and what made it possible to go and fix the constraint properly, upstream, where the fix helps every client rather than just mine. The rest is ordinary engineering done carefully: one abstraction covering five screens, a bounded fetch strategy for a tree the API won't give you whole, caches that make navigation feel native, and release checks that catch the failures which only appear in a signed build. Built for a community rather than a client, and shipped the same way I would ship for one.