devices The whole stack

How I build and run Leafed

A reading app for iOS and Android, the book database behind it, the site you're reading this on, and the pipeline that ships all of it. I build and run the whole thing, so this is the whole thing — what it does, how it works, and why I made the calls I did.

49app screens
29services
622tests, 40 suites
8end-to-end flows
12API endpoints I run
33web pages
53build & release scripts
0ad or crash trackers
On this page menu

System overview#

Almost everything happens right on your device. Screens read through hooks, hooks read and write AsyncStorage, and a set of services owns the logic that outgrew a hook. None of that needs a network.

Only two things reach out to the network, and both are optional: book metadata lookups when you search or open a book you haven't cached, and sync when you've turned it on.

YOUR DEVICE — works with no network Screens 49 files · React Navigation Hooks 33 files · the real storage boundary Services 29 files · sync, sessions, merge AsyncStorage one JSON blob per collection Book data APIs 7 sources, resolved on search and lookup Sync iCloud or account off until you enable it dashed = optional, never required to use the app
Inside the app. Solid boxes run on your device; dashed boxes are optional and conditional.

What it does#

Before the internals, the surface. This is the whole feature set as it stands today — it's a lot for a solo project, and most of it exists because someone asked for it.

Getting books in

  • Barcode scanning — point at a cover, get the book. Three scanner implementations behind one interface, so it works in Expo Go and in native builds.
  • Search across Open Library and my own indie catalog, with author autosuggest and relevance ranking.
  • Imports from Goodreads, StoryGraph, Bookmory and Kindle — CSV and clipping files, with shelves, ratings and read dates preserved.
  • Kindle highlights — drop in your clippings file and every highlight becomes a quote attached to the right book.
  • Manual entry for anything the APIs don't have, including a cover photo from your camera roll.

Organizing

  • Shelves — Want, Reading, Finished, DNF, Paused, Favorites, plus custom shelves and sub-shelves.
  • Quarter-star ratings, mood and pacing tags, and page counts you can correct when the metadata is wrong.
  • Duplicate detection and merge — finds the same book added twice under different identities and merges the records, repointing notes and sessions at the survivor.
  • Never-recommend exclusions, and a saved-for-later list separate from your shelves.

Reading

  • Reading sessions — a live timer or manual entry, with per-read-through tracking so re-reads don't overwrite each other.
  • Annual goals with pace projection, so you know whether you're actually on track.
  • Notes and quotes, per book and per shelf, plus OCR quote capture that reads text straight off the page with the camera.

Looking back

  • Insights — pages, books, genres, streaks, habits, fiction-versus-nonfiction splits, and a time-travel view across years.
  • Author stats — who you actually read, as opposed to who you think you read.
  • Monthly wrapped and shareable book and stats cards, which render on device and need no account on the receiving end.

Discovering

  • Explore — best-seller lists, curated hubs, author pages and adjacency ("if you liked this author, try this one").
  • An indie catalog of small-press and self-published books that the mainstream APIs don't index, which I maintain myself.
  • Series awareness, ownership badges, and buy links through several booksellers.

Owning your data

  • Export to JSON, CSV or Markdown, at any time, with no upsell in the way.
  • Optional sync via iCloud or an account, off until you turn it on.
  • Analytics off by default, and a one-switch delete for everything stored on device.

Nearly all of that works with no signal, because your library lives on the device. The three things that genuinely need the network are the ones you'd expect: searching for a book you don't own yet, the ISBN lookup after a barcode scan, and sync. Everything else — shelving, rating, note-taking, the timer, insights, export — runs offline.

Storage layer#

There's no database. Every collection is a single JSON value in AsyncStorage, read whole and written whole. No SQLite, no ORM, no query layer.

Keys are namespaced under leafed: and defined in one place, src/config/storageKeys.js — 33 of them. Ten hold the library itself and are the ones that sync:

  • leafed:books — the book catalog
  • leafed:shelves — shelf definitions, system and custom
  • leafed:shelfItems — the book-to-shelf links, where ratings, dates and mood/pacing tags actually live
  • leafed:notes — quotes and notes
  • leafed:readingSessions and leafed:readings — sessions, and one record per read-through so re-reads don't overwrite each other
  • leafed:readingGoals, leafed:savedForLater, leafed:excludedBooks, leafed:dismissedDuplicatePairs

The rest are local-only: preferences, timer state, an insights cache, prompt gating, and per-day counters. One key is a deliberate exception to the naming convention — the analytics opt-in is stored as @leafed/analytics_opt_in, and it's one of only two keys that survive a full app-data wipe. The other is the tombstone list, for reasons that matter in the sync section.

What a flat blob costs

Reading a whole collection to touch one record is fine at personal-library scale, right up until it isn't. Reading sessions hit that wall first: scanning every session to answer "what did I read in March" got slow enough that I hand-rolled a date index, leafed:readingSessionsByDate, which is a thing a database would have given me for free.

Migrations are similarly hand-built. There's no schema version — each migration owns a boolean flag key and checks it on startup, five of them so far, running inline during the shelves hook's load rather than from app init. It works, and it's clearly a version counter that grew legs.

The local API layer#

Now the part I could easily have glossed over: there's no storage abstraction layer. No repository, no DAO, no wrapper module. storageKeys.js exports key constants and a clearAppData() helper — it does not wrap getItem and setItem.

55 files import AsyncStorage directly. 41 of them are outside src/services: 12 hooks, 9 screens, 16 utils, 3 components, 1 context.

The de-facto boundary is the hooks, not the services. useShelves is 1,176 lines and is where migrations run, shelves mutate, and writes get marked for sync. That's the real seam in this codebase, and it's a hook.

The services that do exist got extracted when logic outgrew a hook, not as a designed tier:

Service Owns
iCloudSync/The sync engine — SyncManager (1,119 lines), MergeEngine (881), TombstoneManager. The largest subsystem in the app.
sync/Provider abstraction and the optional account layer. Picks iCloud or an account transport at runtime.
ReadingSessionServiceSession CRUD plus the shelf and read-through side effects that fire when a session is saved.
mergeBooksMerging two book records into one, repointing notes, sessions and shelf items at the survivor.
duplicateDismissalsWhich duplicate pairs you've told the app to stop asking about, plus garbage collection.
analyticsServiceThe two-gate analytics setup described in privacy.
aiAvailabilityRate limiting and a circuit breaker for AI features. Currently gated off.
ratingPromptServiceStore-review eligibility — 600 lines and 14 separate trigger points, because asking at the wrong moment is worse than not asking.
revenuecatIn-app purchases. Configured with an API key and nothing else — no user id, no attributes.
tipPrompt, milestoneTip, monthlyWrappedPrompt gating, each with its own persisted state and cooldown rules.

Honestly, this shape grew rather than got designed. It holds together because the storage API is only three methods wide and the key constants live in one place, so routing everything through a wrapper would mostly be ceremony. It would stop holding together the day storage stops being AsyncStorage, and I know it.

Book resolution: several sources, one record#

Leafed reads from seven book data sources. There is no single resolution order, because search, details and barcode scanning are different problems and I ended up solving them three different ways.

Search runs in parallel

Open Library and the Leafed indie database are queried concurrently, then merged. Indie failure is caught to an empty result set, so it can never block or fail a search. If Open Library returns nothing, indie results stand alone.

Indie results are ranked above Open Library. That's a positioning decision, not a relevance judgement — the indie catalogue is the part of Leafed that doesn't exist anywhere else, and merging it by relevance would mean it effectively didn't exist at all.

That decision came back to bite me pretty quickly. The indie API matches loosely: query talk and it returns Stalked by Saint, matching inside the word. Promoted above a properly ranked Open Library page, those rows pushed the real answer off the visible list on exactly the short, common queries that are hardest to search. So there's now a relevance gate that partitions promoted results into ones that earned the placement and ones that get demoted below Open Library. So the gate is really there to clean up after the ranking decision.

Details is a real cascade

Opening a book runs a four-stage fallback:

  1. Open Library work, ratings and editions, fetched together and tolerant of partial failure — each piece resolves independently, so losing ratings doesn't lose the book.
  2. Google Books by ISBN.
  3. Google Books by title and author, but only if the ISBN hit came back thin — no description over 50 characters and no ratings.
  4. Hardcover, last resort only, when there's still no primary data or no ratings from anywhere.

The results are merged field by field with explicit precedence rather than whole-record — titles prefer a stored value, then Google, then Open Library; ratings prefer Open Library, then Google, then Hardcover, and the winning source gets recorded on the record so it's auditable later.

Barcode scanning is a third pattern: Open Library by ISBN, then Google Books, then Project Gutenberg, each wrapped so a failure only warns and moves on.

Identity, three ways

Matching books is done at three different strictnesses on purpose:

Layer Matches on Why this strictness
Search dedupe Exact lowercased title + first author Cheap and runs on every keystroke's worth of results. Deliberately naive.
Sync identity Namespaced hard keys: record id, Open Library work key, ISBN-13, or source + external id Title and author are deliberately excluded. This is the set that can cause a merge or a delete during sync, and no fuzzy signal should ever be able to do that silently.
Duplicate detection Equal ISBN, or equal work id, or fuzzy title ≥ 0.9 and author ≥ 0.7 Fuzzy matches are shown to you, never applied automatically. Only ISBN and work-id matches can be bulk-merged.

A manually entered book has no external identity at all. It gets a generated id — timestamp plus random suffix — and that id is its only hard identity key unless you type an ISBN. Which means a manual book can never be automatically matched against the same book from an API. That's the correct trade-off for avoiding false merges and a real limitation if you type in a lot of books.

Covers

Covers come from Open Library, Google Books, the indie database, or a local file for manual entries. A backfill job fills gaps: once a day, up to 40 books, 300ms apart, walking a five-stage ladder that ends at Apple's search API.

The fiddly part is detecting a cover that isn't really there. Open Library serves a 1×1 pixel rather than a 404 for ISBN-derived cover URLs that don't exist, so the check rejects those URLs by shape before trusting them. Misses get stamped with a timestamp and a version number and retried weekly; hits bump the record's timestamp so the fix propagates through sync like any other edit.

Sync without an account#

You can use every feature of Leafed without an account, and one is never created implicitly. There is an optional passwordless account, and it exists for exactly one reason: it's the sync transport when iCloud isn't available, which on Android is always.

Sync is off by default either way. Nothing leaves the device until you turn it on.

State iOS Android
Signed outiCloud, one JSON file per collection in your own private containerNo sync provider at all. Manual export/import only.
Signed inAccount transport on both platforms, replacing iCloud while signed in.

Conflict resolution

The core rule is whole-record last-write-wins keyed on updatedAt, biased to the local copy on ties or missing timestamps. Not field-level merge. If two devices edit the same book, the newer edit replaces the older one entirely.

That rule governs books, shelves, shelf items, notes, sessions and read-throughs. Five collections deliberately don't use it:

  • Reading goals merge per year, not per record, comparing a timestamp for each year independently — so setting this year's goal on one device can't clobber last year's on another.
  • Saved for later, excluded books and dismissed duplicate pairs are pure unions with no timestamps at all.

The union choice is my favorite one here. For a list of "duplicate pairs I've dismissed", last-write-wins can lose a dismissal, and losing one means the app starts nagging you about a pair you already resolved. Union's worst case is that a dismissal you undid comes back — a nuisance. Last-write-wins' worst case is data loss. So union wins, and undismissing goes through a tombstone instead.

Tombstones

Deletes are recorded as tombstones, and this is probably the fiddliest corner of the whole thing. Two things are doing real work here:

Deletions are always recorded locally, on every platform, even when sync is off. Otherwise anything you deleted before enabling sync gets resurrected by the first merge. This is also why the tombstone list survives a full app-data clear.

Tombstones only apply to local records for books. For every other collection they're applied only to remote-only records. That asymmetry is intentional, and the cost is that delete propagation is partial rather than complete: a shelf or note deleted on one device can survive on another. I took that trade because the alternative is worse — a stale tombstone applied locally could delete a system shelf and orphan every item on it.

The cycle

A local write marks its collection dirty and debounces 500ms before uploading, with three retries on failure. A full sync pulls and merges tombstones first, flushes pending dirty collections, then syncs books and shelves sequentially — because both produce id maps that the remaining collections need to resolve their references — and only then syncs everything else in parallel.

Merging re-reads local storage up to three times to fold in writes that landed during the merge, which was the fix for a class of bug where a fast typist could lose an edit to their own sync.

A repair pass runs at the end: it de-duplicates shelves, reassigns orphaned items to a best-guess shelf based on their own data — a finish date means finished, a start date means reading, a rating means finished — and drops duplicate book-shelf links keeping the newest.

Export and import

Export produces versioned JSON — currently 2.0 — plus CSV and Markdown. It's the same on both platforms; it just matters much more on Android, where it's the only data-portability route when you're signed out.

Import is deliberately not last-write-wins. It's additive and local-preferring: existing books are never overwritten, a rating is filled in only where you don't already have one, and notes are only added for books that have none. Importing a backup can add to your library but can't quietly rewrite it.

Privacy as architecture#

The privacy properties come from the structure, not from a policy. Here is every outbound call the app can make.

Trigger Destination What's sent Optional
Search, book lookup, author suggestionsOpen LibraryYour query text or a book identifierCore
Displaying coversOpen Library coversA cover or ISBN id in the URLCore
Detail enrichment, barcode fallback, importsGoogle BooksTitle, author or ISBNNeeds a configured key
Ratings when nothing else has themHardcoverTitle, author or ISBNFlag + token
Best-seller listsNew York TimesList name and dateFlag + key
Search and indie browsingLeafed indie databaseQuery text, paging and filtersFeature-flagged
Barcode third fallbackProject GutenbergISBN or queryNeeds a key
Cover backfill, final stageApple searchTitle and authorFeature-flagged
Tapping an audiobook linkAffiliate networkThe destination URLOnly on tap
Contact form; optionally submitting a book you typed inLeafed email endpointYour message and email; book title and author if you opt in per bookYou initiate it
Analytics signalsTelemetryDeckAn event name and a small count or labelOff unless you turn it on
Purchases and restoreRevenueCatStore product ids and receiptsOnly if you buy or restore
Sign-in and syncAccount backendYour email, then your libraryOnly if you sign in
Update check on launchExpoApp version metadataPlatform-level

Two of those are unconditional: Open Library search and Open Library covers. Everything else is gated behind a flag, a key, an opt-in, or an action you took.

The two gates on analytics

Analytics require both a build-time flag and a runtime opt-in that you set. The client is not merely muted when you haven't opted in — it is never constructed. The opt-in defaults to off, nothing in the app writes it to on except the switch in Settings, and turning it back off destroys the instance.

When it is on, the user identifier passed is the literal string anonymous. Signal payloads carry an event name and at most a version string, a source label, a count, or a trigger name. No book titles, no authors, no ISBNs, no search queries, no email, no device id.

What signing in actually does

I'd rather say this plainly than have you find out later: signing in sends your book titles and reading history to a Leafed-controlled server. That's what sync is. Rows are isolated per user at the database level.

Two things are not sent. The raw API payload cached on each book — several kilobytes of Open Library and Google Books response — is stripped before upload and restored locally afterwards. And covers for manually entered books are local files, so they don't transfer, which is why a manual book can arrive on a second device without its cover.

Authentication is a magic link with a six-digit code fallback. No password, no social login, no phone number. Signing out keeps your local data. Deleting your account keeps your local data too.

What isn't there

No crash reporter. No ad SDK. No Sentry, Crashlytics, Firebase, Mixpanel, Amplitude, Segment, PostHog or Facebook SDK — the app has no advertising identifier permission and no tracking-transparency prompt, because there's nothing to ask for. Errors are caught by an in-app boundary that logs to the console and offers a retry.

One honest caveat on "no trackers": RevenueCat, Expo's update service and the affiliate network are third-party endpoints too. They're not analytics, and none of them receive your library, but I'd rather name them than claim a tidy zero that quietly skips past them.

One deliberate gap in this page. The indie database has a public submission path, and I've left its request shape, field names, paths and throttling behavior off this page on purpose. Same for keys and internal routes. You'd notice the hole either way, so I'd rather name it than pretend the page is exhaustive.

The part that isn't the app#

Open Library and Google Books don't index much small-press or self-published work, which is a real gap if that's what you read. So Leafed has its own book database, and I run it: a public submission form, a moderation step, and an admin panel behind it.

It's deliberately boring technology — PHP and MySQL on shared hosting — because it needs to keep working with no attention for months at a time, and because the app is designed to survive it going away entirely.

Piece What it does
Public API12 endpoints serving search and book detail to the app and to the site's browse page, in an Open Library–shaped response so the client treats it like any other source.
Submission formAnyone can submit a book. Bot-protected, and nothing reaches the catalog without passing through moderation first.
Admin panelSession auth against a hashed password, edit, soft-delete and restore, and an audit log recording who changed what, when, and from where.
Cover pipelineSubmitted cover URLs get fetched, re-encoded to a consistent 600×900 JPEG, and self-hosted rather than hotlinked — so covers don't break when someone else's bucket goes away.
AnalyticsSearch and view logging on the catalog side, which tells me which books people look for and don't find. That's the queue for what to add next.

The cover pipeline is the piece I'd point at. It fetches user-supplied URLs, which means it's a server-side request forgery hole if you write it naively — so it validates the scheme and rejects private and reserved IP ranges before it will fetch anything, and re-encodes rather than trusting the bytes it gets back. I'd rather build that carefully once than find out the interesting way.

Tooling I built for myself#

Adding books to the catalog one form submission at a time doesn't scale past about ten, so most of what I built next was for me rather than for users.

  • A batch submission CLI that takes a CSV and works through it, plus a small local web UI for when I'd rather see what's happening than read a log.
  • An email watcher that polls a mailbox every ten minutes and ingests book submissions straight out of it, with a state file so nothing gets processed twice. Publishers and authors email me about their books; this turns that inbox into a queue instead of a chore.
  • Single-file deploys. Pushing the whole site to fix one page is how you break three other pages, so one-page changes upload exactly one file.

None of this is glamorous and all of it is the reason the catalog exists at all. The operational work is usually what decides whether a side project survives its first year.

Shipping discipline#

Feature flags

94 flags in one plain object, imported by 104 files. No remote config, no override UI, no environment switches except two that derive from whether an API key is present. Flipping a flag means a rebuild.

That's a real constraint — I can't dark-launch or kill a feature remotely. What I get instead is that the flag state is whatever shipped, provable by reading one file, with no drift between what I think is on and what's on for a given build.

Most flags gate route registration and rendering; the code is still bundled. Three change behavior structurally rather than visually: one removes a collection from the syncable set entirely, one selects a different merge algorithm, and one prevents the account SDK from ever being loaded.

Tests

622 tests across 40 suites, all passing. The coverage isn't even, and the shape of it is a fair map of what worries me: sync and merge logic carry the most tests, followed by insights, title matching, security, search relevance, duplicate detection, and migrations.

There are no component render tests and no tests for the sync manager itself — it's mocked in the tests for everything that depends on it, which means the orchestration layer is verified by hand and on device rather than in CI. The merge engine underneath it, which is the part that could actually lose your data, is a pure function over two arrays and is tested heavily. That order was deliberate rather than accidental — though it's still a gap I'd like to close.

Getting it out the door

Two app stores, a web app, an API and a marketing site, all shipped by one person. That only works if releasing is boring, so most of the effort has gone into making it boring.

  • 53 build and release scripts. Cloud builds and local ones, debug APKs and signed bundles, TestFlight, and Play's internal and closed tracks each with their own command. I don't want to be remembering flags at 11pm.
  • Two CI workflows that deploy the API and the site on their own, so a content fix doesn't require me to be at my desk.
  • 8 end-to-end flows that drive the real app on a real device — launch, tab navigation, search, library, insights, settings, the add-a-book flow, and a full smoke test — sitting on top of the 622 unit tests.
  • A staged deploy habit: upload under a temporary name, check it live, then promote it. Cheap, and it has caught things a local preview didn't.

There's also a real constraint I designed around: Android now requires 16KB memory page alignment, which meant auditing native dependencies for compliance rather than discovering the problem at submission time. Platform requirements arrive whether or not the roadmap has room for them.

Knowing when to ask#

The engineering decisions on this page are mostly about correctness. This one isn't, and it's the code I've rewritten most.

The store-review prompt is 600 lines and eleven separate trigger points — after a reading session saves, after a goal completes, after an import finishes, after a five-star rating, after a share. Never on a cold launch, never mid-task, never twice.

The reason it's that long is that asking at the wrong moment is worse than not asking at all. A prompt that interrupts someone mid-sentence in their notes costs you the review and some goodwill. A prompt right after they finish a book they loved is a fair thing to do. Encoding "a good moment" turns out to take a lot more logic than encoding "thirty days after install".

The same shape covers the rest of the asks. Tip prompts, the monthly wrapped, and milestone nudges each carry their own persisted state and cooldowns, each has an escape hatch that stops them permanently, and all of them are off in the moments where they'd be irritating. Support for the app is a tip jar rather than a paywall — nothing in the feature list above is gated behind paying me — and books link out through several booksellers rather than just the obvious one.

None of that is technically hard. It's the part that decides whether people keep the app, which makes it worth more attention than it usually gets.

Why I built it this way#

AsyncStorage instead of SQLite

It was the fastest thing that worked, and a personal library is small enough that reading a whole collection into memory has never been the bottleneck. The cost is real: no indexes, no queries, and a hand-rolled date index for sessions because scanning them all got slow.

There's a compensating benefit I didn't plan for. Because a collection is one readable, writable unit, the merge engine is a pure function over two arrays. With rows in a database I'd have needed row-level change tracking before I could sync anything at all.

I'd pick SQLite starting today. I'm not migrating 100,000 lines to prove a point.

Whole-record LWW instead of field-level merge

Field-level merge is better on paper and needs per-field timestamps to work — which means either a schema change on every record or a shadow structure tracking modification times per field. On a flat JSON blob that's a lot of machinery for the actual conflict rate, which for one person on two devices is close to zero.

The cost is that a genuine simultaneous conflict loses the older edit entirely — rate a book on your phone and add a note on your iPad within the same sync window and one of them can lose. The exceptions in the sync section are the places where that felt too steep, so I paid for something better.

No required account

The app had no account layer at all for most of its life, and every design decision downstream assumed that. It's why data is local-first, why sync was iCloud-only, and why there's no server-side user identity to leak.

Adding an optional account was the only honest answer to Android having no iCloud. The cost is a genuine complication of the privacy story: "we have no accounts" was easy to say and easy to verify, and "you can use everything without an account, and here's exactly what changes if you make one" needs a paragraph and a table. That's this page's job.

Several book sources instead of one

Open Library is the backbone but has real gaps in descriptions, page counts and ratings. Google Books fills those and has its own gaps. Hardcover exists in the chain purely as a ratings backstop.

The upshot is that a book isn't one single thing. It's a merged record with per-field precedence and a recorded provenance for ratings, and the identity code has to be careful enough that merging never silently deletes. That's where most of the tricky code lives — MergeEngine and the identity helpers, not the UI.

Build-time flags instead of remote config

Covered above: no remote kill switch, in exchange for zero drift and no config service to run, secure or pay for. For a solo-maintained app I'd make this trade again.

Where this is going#

Leafed is a labor of love. Nobody is paying for this, there's no team and no roadmap pressure, and features land when I finish them. That's worth saying plainly, because it explains both the pace and the choices — I optimize for things I'd still want to be maintaining in two years, rather than for a launch.

Learning to hold user data properly

The optional account is the newest and most interesting part of the system, mostly because of everything it brings along with it. Local-first means the worst case is one person's device. The moment there's a server, the questions change: what's the retention story, what does account deletion actually delete, how do you migrate a schema under live clients that may be several versions behind, how do you know a sync bug happened at all when you deliberately collect no telemetry about people's libraries.

That's the biggest thing this project is teaching me at the moment. The design goal is that the server stays the least interesting component — a transport with row-level isolation and nothing clever in it — so that turning it off would cost you sync and nothing else.

A rebrand

I'm planning a change of name and a visual redesign. The current identity was a placeholder that stuck, and the app has grown past it. The architecture on this page is the part I intend to keep; the surface is the part I intend to redo.

Catching up on table stakes

There are features other reading apps have that Leafed simply doesn't yet, and some of them are just work I haven't done. Those get picked off in the order people ask for them — there's a public voting board for exactly that reason, and it's the closest thing to a prioritization system I've got.

On-device intelligence, if it can stay on-device

The direction I'm most interested in is what's now possible locally: on-device models and embeddings good enough to power semantic search over your own library, better recommendations from your own reading history, and smarter matching for the identity problems described above — without a request leaving the phone.

That's the only version of those features I want to ship. An earlier bookshelf-scanning feature that depended on a cloud vision API was retired rather than kept, and the AI chat assistant sits behind a flag that's off. If the fun version of a feature means sending your library off to someone else's inference endpoint, it doesn't ship.

The constraints stay fixed regardless: privacy, offline-first, optional sync, and no social feed.

Known work in the queue

A few things mentioned elsewhere on this page, gathered here so they're easy to find: adding real connectivity detection so the app stops inferring offline from failed requests; closing the Android gap where signing out means no sync at all; making delete propagation converge for collections other than books; and making the importer read the version field it already writes.

Try it#

Leafed is free on both stores. If you've read this far and want to argue with any of it, I'd genuinely like that — get in touch.