Inkwell 2.0 is out for iOS and Android.
The last public releases were iOS 1.0, build 50 and Android 1.3.1, versionCode 6. Those two numbers say a lot about the state the project was in. iOS shipped one marketing version and fifty builds under it — I never once bumped the minor, I just kept incrementing the build number and telling AltStore it was still 1.0. Android went 1.0.0, 1.0.1, 1.1.0, 1.2.0, 1.3.0, 1.3.1, six versionCodes, actual semver. The two apps weren't drifting on features alone; they weren't even being versioned by the same scheme.
2.0.0 is the first release where both platforms carry the same number, and that's not cosmetic. It's the first release where they're the same program.
It's also — and I'll be upfront about this, because it explains most of what follows — the first mobile app I have ever actually published, on either platform. Not the first I've written. There's a SwiftUI Bluesky client sitting in my repos that nobody has ever installed, and I've experimented with clients for the Wii and the Wii U, plugins for Minecraft servers, and games in Godot. Writing something that runs is a solved problem for me. Getting it onto a phone that isn't mine, through a channel a stranger can install from, turns out to be a different discipline with its own category of mistake, and this post is largely a list of those.
The shift: one core, two shells
Until last week, Inkwell was two codebases that agreed on a protocol and nothing else. Both apps parsed markdown. Both converted facets between UTF-8 byte ranges and rendered text. Both built Standard.site verification URLs, scanned pages for discovery link tags, paginated Constellation backlinks and deduplicated the results, and decided which of Leaflet, Markpub, pckt or Offprint a given record was. Two implementations of every one of those, in two languages, written months apart.
That arrangement fails in one specific, boring way: the implementations don't diverge loudly, they diverge quietly. One of them handles inline code followed by plain text correctly and the other drops a character. One caps a record-list page at a different number. Nothing crashes. The two apps just gradually stop agreeing about what a document says, and you find out when someone shows you a screenshot.
So 2.0's actual headline, the reason it's a major version at all, is that the duplicated half is gone. There's now a Kotlin Multiplatform core module — shared/, 61 source files — that owns everything that isn't inherently a platform concern:
Markdown parsing and serialisation, and the block model underneath both
Facet conversion in both directions, including the byte-offset arithmetic that's easy to get subtly wrong in exactly one of two languages
AT-URI parsing, collection NSIDs, XRPC endpoint constants, CDN URL construction
Verification URL building, discovery link-tag scanning, publication matching
Constellation pagination and backlink deduplication
A neutral shared-model layer so both platforms map the same DTOs
Android consumes it directly, because it's Kotlin. iOS consumes it through InkwellShared.xcframework behind SharedKMP.swift, which is a bridge and deliberately nothing else — 582 lines of thin wrappers plus 212 lines of model mapping, and no business logic of its own.
What stays native is what genuinely has to: SwiftUI and Compose, Keychain and EncryptedSharedPreferences, the OAuth browser handoff, BGTaskScheduler and WorkManager. The rule the project now runs on is that if a piece of logic could live in shared/, it must, and platform code that reimplements something the core already does is a bug rather than a style preference.
The migration ran over about two days and closed with iOS dropping its own ATURI struct entirely in favour of the shared parseAtUri(). That commit deleted a type I'd written, tested, and been perfectly happy with, which is the correct outcome and still felt bad.
Android stopped being the other one
The direct consequence of a shared core is that Android could finally catch up on features without me writing every protocol behaviour a second time. Between 1.3.1 and 2.0 it gained:
Reader publication theming — the full Leaflet rich-theme cascade through
basicThemeto Material 3 defaults, matching how iOS resolves itMarkpub markdown rendering with headings, lists, code blocks, blockquotes, images, task lists, horizontal rules and inline formatting
pckt and Offprint block arrays converted through the shared converters, with facet-aware inline formatting and nested list support
Live Bluesky post embeds with author, images, links and quote rendering, plus Standard.site post embeds with document fetch and cover image
Interactive poll voting in post detail
Image upload and blob handling in the writer, and document edit/update with revision support
Subscribe actions, sharing, and verification caching, with verification badges on feed cards
WorkManager background notification polling, which iOS had via background refresh and Android simply didn't
Plus a design pass aligning it with the ink-and-paper visual language iOS already used. Android went from a client that could read some formats and write plain documents to one that does approximately everything iOS does. That's the bulk of what changed in 2.0 by volume, and almost none of it required new protocol code, because the protocol code already existed in shared/.
Both writers grew up
The writer on both platforms is now a split-pane editor: markdown on one side, live preview on the other, a formatting toolbar across the top, selectable output format, image upload, and loss reporting when you convert between formats that don't round-trip cleanly. The format buttons became cursor-position-aware on both platforms in the same pass, which is the sort of thing that's trivial per-platform and irritating to keep in sync when it's written twice.
There's also in-app feedback now, posting to Inkwell's userinput.app board, so bug reports don't depend on someone finding me on Bluesky.
Discover is a real tab on iOS
Android has always had a three-tab bottom bar: Read, Discover, Write. iOS had two tabs and a floating search bubble that opened a search-first Discover screen, because iOS offers a dedicated search-role tab and using it looked like the platform-native call.
It wasn't worth the divergence. The search role forces .searchable onto the TabView rather than the view that owns the query, Discover can't be an ordinary destination, and the two apps end up with genuinely different information architecture for the same three features. Platform fidelity is supposed to govern presentation — lifecycle, controls, idiom. A different navigation model isn't presentation, it's a fork.
Discover is now a normal third tab on iOS with its own search field. Three smaller gaps closed alongside it: author names on reader feed cards, publication descriptions and format tags in Discover rows, and a visible subscribed state (filled bell versus outline). Two of those three turned out to be bugs in the screenshot fixture rather than the views — the code was right and every capture I'd ever taken was wrong, which is its own small lesson about what capture-mode code is quietly allowed to get away with.
The minimum iOS version was never a decision
Inkwell's iOS deployment target was 26.0, and the test target 26.5. I never chose either number. They're what the Xcode beta I started in defaulted to, and once a deployment target is set nothing ever mentions it again — the app builds, the simulator runs it, and the number sits in project.pbxproj looking like something somebody decided.
It's worth saying where those defaults come from: I develop on the macOS 27 public beta with the matching Xcode beta. That's a pleasant place to work and a terrible place to judge what anyone else can run. Every default my toolchain hands me is calibrated to an OS most people won't see for months, my simulator list is full of runtimes nobody has, and every build succeeds. Nothing in that setup produces a single signal that the floor is too high.
So Inkwell required the current release of iOS — not "a recent iPhone", the newest OS on a device already updated to it — for all fifty builds, and everyone else got told it was incompatible.
The question is never "what's the lowest number I can type in", it's "what's the oldest OS that has every API this codebase actually calls". Going through the SwiftUI surface turned up exactly two things that genuinely need iOS 26:
tabBarMinimizeBehavior(.onScrollDown), which shrinks the tab bar away as you scroll into an articlesafeAreaBar(edge:), which gives the Reader's Following/Yours switcher its scroll-edge effect
Two cosmetic effects. Both now sit behind if #available(iOS 26.0, *) — the tab bar doesn't minimise on older versions, and the switcher falls back to safeAreaInset, which does the same layout job without the effect. Nothing else needed anything newer.
The floor is now 18.0, app and tests. That's roughly iPhone XR/XS-era hardware onwards, which is a different universe of devices from "updated to the newest release in the last few months". It isn't a feature I built; it's a constraint I imposed by accident and then removed.
Things that were doing nothing, loudly
Dogfooding the 2.0 branch turned up a crash on launch and a real DPoP/PKCE bug in the OAuth flow — the sort you only hit against a live PDS, not a mock. Both fixed before release.
The quieter three:
The Write tab's formatting toolbar has an image button. On iOS it was wired to onImagePicker: {}. An empty closure. It rendered, it was enabled for every format that supports images, and it did nothing at all; the only working upload control was a second PhotosPicker in the section header that you'd only find by not using the toolbar.
The "Editing existing document" banner used Image(systemName: "edit"). There is no SF Symbol called edit. systemName: takes a String, so nothing catches it at compile time — at runtime it logs a miss and lays out an empty frame, which is why the banner had an unexplained gap where a pencil should be.
On Android, Send Feedback was one-shot per launch. The dialog's ViewModel comes from hiltViewModel(), which scopes to the enclosing NavBackStackEntry rather than the dialog, so it outlives dismissal. submit() sets submitted = true; reset() existed, correctly written, with zero callers anywhere. Second time you opened it in a session you got the "Thanks!" screen and a Done button, no form, until you killed the process.
None are dramatic. All three are the same shape: something that compiles, renders, and silently does nothing.
Fifteen files that had become filing cabinets
The other half of the architecture work is inside each platform. LoginStateManager.swift was 1,617 lines covering OAuth, DPoP, PDS resolution, public and authenticated XRPC, records, blobs, subscriptions, recommends, comments, profiles and caching. LeafletBlockRenderer.kt was 1,123. ReadView.swift 1,212, ContentProvider.swift 890, PostDetailScreen.kt 751, PdsRepository.kt 501.
Fifteen files across the two apps are now split along responsibility lines. LoginStateManager.swift is about 120 lines plus focused extensions — +Auth, +XRPC, +Records, +Blobs, +Documents, +Publications, +Subscriptions, +Recommends, +Comments, +Profile — with the error type in its own file. Android gained PdsRepositoryDocuments.kt, PdsRepositoryGraph.kt, LeafletListBlocks.kt, PostDetailBody.kt and a dozen siblings. Rendering split into one provider file per content format behind a registry, instead of one file that knew about all four.
Pure moves, no behaviour changes. The only non-mechanical edits were access modifiers, and specifically Swift's: private doesn't cross file boundaries even between extensions of the same type, so a lot of members had to widen to internal purely because the code moved. Worth knowing before anyone attempts the same split.
CI, finally
There was no CI. There is now — a path-filtered GitHub Actions workflow that only builds the platform you touched, because most commits in a monorepo like this touch exactly one.
Standing it up immediately found three things that were only true on my machine. gradle.properties hardcoded org.gradle.java.home to Android Studio's embedded JDK at a macOS-only absolute path, so the Android build worked for precisely one person on earth. The website's Prettier check was scanning vendored tooling and fdroidserver-generated output, failing on 113 files. And the iOS job needs Xcode 26+ regardless of the new iOS 18 floor, because the app icon is an Icon Composer .icon bundle and older Xcodes don't recognise the format — they silently compile the asset catalogue alone, find no icon set, and emit an error that reads like a missing file rather than a wrong toolchain. That one cost me the most time, and I misdiagnosed it twice before reading the log properly.
All three are the same failure as the deployment target: I'd been treating a beta machine as the reference environment. A stock runner is the first thing this project has ever had that disagrees with my laptop, and it disagreed immediately, three times. That's the argument for CI on a one-person project — not catching regressions, which I'd mostly catch anyway, but keeping one machine in the loop that isn't configured like mine.
Getting it
AltStore Classic for iOS (source URL), the self-hosted F-Droid repo for Android. Neither app has an App Store or Play Store listing and this release doesn't change that.
For iOS it's AltStore Classic, not PAL — Inkwell isn't notarised, so it has no marketplaceID and PAL will refuse the source outright citing exactly that. Classic is the original free sideloading one that needs a computer for first install and refreshes every seven days.
Two apps, one core, one version number — and the first thing I've built that other people can actually put on their phones. If you tried Inkwell before and yours told you it wasn't compatible, that was me, and it isn't true anymore.