54 KiB
Building
What to build next, and what has been ruled out, is in
docs/ROADMAP.md.
What each supported server exposes, what is implemented against it, and
why the remaining gaps are or are not fixable is in
docs/SERVER_CAPABILITIES.md.
This project uses the Gradle wrapper, so you don't need Gradle installed
separately: just a JDK 17+ and the Android SDK (command-line tools are
enough; compileSdk/targetSdk 37 requires a reasonably recent SDK
Manager package list).
./gradlew assembleDebug # unsigned debug APK, installable as-is
./gradlew assembleRelease # minified release APK
./gradlew bundleRelease # minified release AAB, for Google Play only
Output APKs land in app/build/outputs/apk/{debug,dev,release}/, and the
bundle in app/build/outputs/bundle/release/.
The debug build is signed with the standard Android debug key, so
app-debug.apk can be installed directly with adb install or by
sideloading. The release build is unsigned by default so the project
builds out of the box on any machine/CI; see below if you want a signed
release build.
The dev build, for testing on a phone you use
The debug build carries the production applicationId, so installing it
on a phone that already has Liseur on it replaces Liseur. The library
that goes with it — reading positions, highlights, notes, sessions —
does not come back.
The dev build type exists so that does not have to happen:
make dev # ./gradlew assembleDev
make dev-install # adb install -r, beside the real app
make dev-run # install and launch
make dev-uninstall
make dev-logcat # logcat filtered to its pid
It is the debug build with two differences, both deliberate:
applicationIdiscom.chmouel.liseur.dev. Android keys an app's storage off that, so the dev build gets its own Room database, its own DataStore and its own SAF grant. It cannot read or damage the real app's library, and the first thing it will ask for is a folder, because it genuinely has none. Connecting a server is likewise a fresh connection, with its own device key.- The launcher name is "Liseur (dev)" and the icon field is retinted, so
the two are told apart before either is opened. That is the whole of
app/src/dev/res/.
The APK lands in app/build/outputs/apk/dev/app-dev.apk, debug-signed
like app-debug.apk.
release is not involved anywhere in this, which is the point: F-Droid
rebuilds that build type byte for byte from the tag, and nothing here
changes what it produces. debug is not involved either, so
make install, make run, make reset, hack/screenshots,
hack/e2e-* and the scenarios under tests/ keep the package name they
hardcode and go on working against the emulator unchanged.
One thing to watch if you drive it by hand: adb shell am start -n com.chmouel.liseur.dev/.MainActivity does not work. The .Class
shorthand is relative to the application id, and the dev build's no
longer matches the namespace the class lives in, so the component has to
be spelled com.chmouel.liseur.dev/com.chmouel.liseur.MainActivity.
The Makefile already does.
Signing a release build (optional)
The real signing key for published releases lives in pass:
| Entry | Contents |
|---|---|
android/liseur.keystore.p12 |
the PKCS#12 keystore, base64 |
android/liseur.keystore-password |
store and key password |
android/liseur.keystore-alias |
key alias (liseur) |
To build a signed APK locally with it, write a keystore.properties
(gitignored, per-developer) pointing at a decoded copy. Keep that copy in
persistent user data rather than /tmp, which may be cleared between builds:
signing_dir="${XDG_DATA_HOME:-$HOME/.local/share}/liseur/signing"
install -d -m 700 "$signing_dir"
umask 077
pass show android/liseur.keystore.p12 | base64 -d > "$signing_dir/release.p12"
{
echo "storeFile=$signing_dir/release.p12"
echo "storePassword=$(pass show android/liseur.keystore-password)"
echo "keyAlias=$(pass show android/liseur.keystore-alias)"
echo "keyPassword=$(pass show android/liseur.keystore-password)"
} > keystore.properties
If keystore.properties exists but its storeFile has been removed, Gradle
stops during configuration with recovery instructions. Run the commands above
again to restore the decoded keystore and refresh the properties file. Delete
keystore.properties only when you deliberately want the unsigned build used
by clean checkouts and F-Droid.
hack/release performs this check before a release and runs the same recovery
automatically when the properties file or decoded keystore is missing. It
leaves an existing usable custom signing path unchanged.
Contributors without access to that key can generate their own instead. Any key produces an installable APK; it simply won't update over one signed with the release key:
keytool -genkeypair -v -keystore /path/to/your.p12 -storetype PKCS12 \
-alias liseur -keyalg RSA -keysize 4096 -validity 10950
Either way ./gradlew assembleRelease picks the file up automatically;
without it the release build is simply unsigned.
Testing
./gradlew testDebugUnitTest # JVM unit tests
./gradlew lintDebug # Android Lint (0 errors required)
There is no instrumented/emulator test suite. Reader interactions (gestures, immersive mode, process-death restore, rotation) are verified manually on a booted AVD.
The upload end-to-end check
Sending a local book to liseur-sync is the one path where a unit test
proves almost nothing: what can go wrong is the shape of the whole
round trip, not a function. hack/e2e-upload drives it through the
app's own screens against a real server and then reads both sides.
hack/e2e-upload -u http://10.0.2.2:8686 -t <token> -d /srv/books
-u is the server as the device sees it, so an emulator wants
10.0.2.2 and never 127.0.0.1. -t is a device token holding at
least sync, library-read and library-upload. -d is the watched
folder's root on this machine, which is how the script sees what landed.
The folder has to accept uploads already:
liseur-sync admin folder-uploads <folder-id> on
It asserts four things, and each one is a bug that actually happened:
the library-upload scope reaches the app as can_upload, the files
appear in the folder, no uploaded book had its url rewritten (that
key is what every reading position hangs off), and no book came back
from the following catalog pass as a second row. That third one is
worth the whole script: a book uploaded from the device was being
deleted by the next refresh, and its reading position with it.
The other half of the capability is refusing, and -r checks it:
hack/e2e-upload -r -u http://10.0.2.2:8686 -t <token> -d /srv/books
A server says no in two places. A token without the scope is refused on
sight, and the action is never offered. A token that holds the scope but
finds every folder closed can only be refused by trying, and the app has
to remember the answer; otherwise it offers, once per book, an action
that silently fails every time. Either way -r asserts the app ends up
not offering, no book was linked, none left the shelf and nothing was
written into the folder. Run it with the folder still closed, then
folder-uploads <folder-id> on and run the check above, and you have
covered both answers with one server.
Nothing about it is mocked. Start with a clean shelf (hack/reset-books
then adb shell pm clear com.chmouel.liseur), or the counts it compares
are counting an earlier run.
Checking a folder's storage permission
Deleting a book's file needs write access to the folder it lives in, and that access is granted once, by the system picker, when the folder is added. A folder added by a version of Liseur older than "deleting a book deletes the book" was only ever granted read.
What the system actually holds is worth checking directly rather than inferring from behaviour:
adb shell dumpsys activity permissions | grep -A2 targetPkg=com.chmouel.liseur
mode=0x1 is read only, mode=0x3 is read and write, and persisted
says which of those survives a reboot. Adding the same folder again
through the picker upgrades a read-only grant in place, which is what
the "add it again to grant deletion" wording in delete_local_failed
is telling the user to do.
Worth knowing: on AOSP 16 deletion succeeds even from a read-only
persisted grant, so a stale grant does not reproduce the failure there.
The fallback in LocalLibraryRepository.addFolder and the message that
goes with it are for the devices where it does.
Why the reading stats say "Counting this device only"
The dashboard combines every device that has signed in to liseur-sync, but only when the merge can be proved exact. When it cannot, the screen silently shows what this device counted and says so. That is deliberate: ADR-0021 would rather report a smaller true number than a plausible wrong one, and a reader is never shown an error about it.
The reason is written to the log instead, so the answer is one command away:
adb logcat -s liseur-sync-insights
Every refusal prints Statistics count this device alone: <reason>.
The reasons are prose, not codes, and name the exact thing that could
not be established, from the account having no identity to check a
reply against, through a book resolved to a different work since it was
sent, to a reason the server itself gave.
The one worth recognising is the server could not place this device's evidence (candidate_payload_mismatch). It means this app rebuilt the
description of a sitting and the server disagreed with its own stored
copy. In practice that means the device identity behind those uploads
changed, since the server folds the device id into the fingerprint it
compares. A reconnect with a password keeps the stored device id and is
fine; a pasted token names nobody and gets a fresh one.
Some background on why any of this needs rebuilding. Sessions have been
uploaded since v0.6.0, but the table that retains the exact bytes of
each request only arrived with the dashboard. Every sitting older than
that is of unknown standing: the upload flag does not settle it either,
because disconnecting an account clears the flags while the server
keeps the sessions. So the sitting is described again from the stored
row and offered as a candidate, and the server rules on it. That
rebuild is byte-identical to the original because the payload is a pure
function of the row, and the only field added since, active_ms, is
withheld from exactly these sittings.
Two cases are settled without asking. A sitting recorded since the evidence table existed and never sent is counted here, because a request is always written down before it goes out. A sitting of a book the server has no confident name for is also counted here, because sending reading up requires such a name and the upload query joins on it, so that sitting has never once been selected.
Reproducing the upgraded state on an emulator is the quickest way to exercise all of this: flag the sittings, drop their retained requests and clear their upload flags.
adb shell "run-as com.chmouel.liseur sqlite3 databases/liseur.db \
'UPDATE reading_sessions SET legacy_evidence_unknown = 1, uploaded_at = NULL; \
DELETE FROM session_transmission;'"
The screen should still read "Counting all your devices", and the sitting count should equal this device's own, not that plus the server's. Never do this on a phone somebody reads on.
Releasing
versionName, write the F-Droid changelog, run the tests, lint and a
release build, commit, tag and push, publish the GitHub release, and
update the F-Droid submission.
Run it from a clean, up-to-date main branch, with nothing after it:
hack/release
It shows what has landed since the last release, grouped by commit type, says so when the screens have changed since the screenshots were last taken, offers the next patch, minor and major version, and opens an editor on a changelog drafted from those same commits. Correct the draft, save, and confirm.
The version can also be given outright, which is what CI and scripts want:
hack/release -n 0.2.1 "Fix page fitting on tall screens."
hack/release --fdroid-only 0.2.1 # re-run just the F-Droid step
hack/release --no-play 0.2.1 "..." # leave Google Play out of this one
-n (--non-interactive, --yes) answers the confirmation prompts,
and any run with nothing on stdin needs it: the prompt reads
end-of-file as no and the release stops without having done anything.
That applies to --rc too.
It refuses to run on a dirty tree, off main, out of sync with the
remote, on a version that is not newer, or with release notes over the
500 characters F-Droid allows. An interrupted run can be resumed by
invoking it again with the same version.
Pushing the tag is what starts .github/workflows/release.yml, which
builds and signs the APK in the release GitHub environment and
attaches it to the release. Signing is what that environment must hold:
LISEUR_KEYSTORE_BASE64, LISEUR_KEYSTORE_PASSWORD,
LISEUR_KEY_ALIAS, LISEUR_KEY_PASSWORD and, for the release notes,
GEMINI_API_KEY. Without them there is no release.
LISEUR_PLAY_SERVICE_ACCOUNT_JSON is the odd one out. It buys the third
channel rather than the release itself, and the workflow skips Google
Play entirely when it is absent. hack/release uploads it with the rest
all the same, so in practice a release goes to Play unless you say
otherwise:
hack/release --no-play 0.9.0 "..."
The script records that per-release choice in the annotated tag, so an
already configured Play credential cannot override it. Deleting the
secret by hand is neither necessary nor useful: a later ordinary release
will restore a missing credential from pass.
hack/release creates the environment and uploads whichever secrets are
missing straight from pass, so an unlocked password store is the only
setup a fresh clone needs. To refresh them all, after rotating the key
for instance:
hack/release --sync-secrets
Signing a build on your own machine is a separate matter, covered under Signing a release build above.
Release candidates
A release candidate is the same build, signed with the same release key, put where a handful of people can install it before the final release:
hack/release --rc # the next patch, numbered -rc.1, then .2
hack/release --rc 0.16.0 # a version you are working towards
hack/release --no-play --rc # skip Google Play testing tracks
It publishes a GitHub prerelease with the signed APK attached, uploads
the app bundle to Google Play's internal and closed (Testing)
tracks, and stops there: no F-Droid merge request, no changelog to
write, and no Google Play production upload. Before the tag is pushed,
the generated RC commit must pass the JVM tests, Android lint, and the
release build in its detached worktree. The reproducibility check remains
part of the final release path. Pass --no-play to leave Google Play out.
The signature is what makes it worth doing. It is the one F-Droid publishes under, through the dual-signing flow, so the APK installs straight over a copy that came from F-Droid, with no uninstall and no lost library, and F-Droid offers the next real release over it afterwards, as an ordinary update.
That last part is only true because the RC takes a
versionCode and the next real release lands above it. hack/release
counts the next code from the highest one across main and every
tag, so an RC at 32 pushes the following real release to 33, and
F-Droid sees an upgrade. Nobody who installed an RC is stuck,
but they are on it until the next release goes out, since F-Droid
will not offer a lower versionCode.
Nothing lands on main. The commit that bumps the version is reachable
only through its tag, so main stays a history of real releases and the
next one still bumps from the last real version. Run it from any branch,
as long as the tree is clean.
F-Droid is told nothing about an RC, but it reads the public tags.
v0.9.4-test.1, the old prerelease spelling, was once picked up by its
checkupdates bot, which
opened a merge request proposing it as the current version. The
fdroiddata metadata therefore filters what the bot looks at, with
UpdateCheckMode: Tags ^v[0-9.]+$; neither -rc.N nor the legacy
-test.N spelling matches. Its AutoUpdateMode: None also means builds
are submitted only by hack/release, but the tag filter remains necessary:
the update checker and build submission are separate mechanisms. Before
any final or RC tag is pushed or submitted, hack/release runs
hack/verify-fdroid-tags against that live metadata. It fails closed if a
final tag would be missed or either prerelease spelling would be discovered,
so a future metadata or tag-format change cannot silently put an RC on
F-Droid.
Run make verify-fdroid-tags for the deterministic local regression checks,
or run
hack/verify-fdroid-tags v0.15.0 v0.15.0-rc.1 v0.15.0-test.1
to check the live fdroiddata filter directly. A change to the RC tag format or the
UpdateCheckMode pattern must update and rerun this policy check.
To install one, download the APK from the release page and
adb install -r, or hand it to whoever is testing.
GitHub release pages are retained more narrowly than their tags. After an
RC is published, hack/prune-prereleases deletes every legacy -test.N
release page and keeps only the highest -rc.N page for each target
version. Its APK goes with the page. The tags are never deleted: they keep
versionCode allocation monotonic, number later candidates correctly, and
remain burned names under GitHub's immutable-release rules.
Google Play
Play is the third channel, after the GitHub release and F-Droid, and it
is deliberately the least load-bearing of the three. The same tag that
publishes the release also builds an app bundle and pushes it to the
internal testing track, then promotes that same build to the
closed track named Testing:
make bundle # ./gradlew bundleRelease, for Play only
The second track is not decoration. Internal testing holds a hundred
hand-listed addresses and hands out its own opt-in link; the closed
track is the one testers are recruited into (issue #62), the one
https://play.google.com/apps/testing/com.chmouel.liseur leads to, and
the only one whose opted-in testers count towards the twelve Play wants
for fourteen days before a personal developer account may publish.
Uploading to internal alone, which is what happened up to 0.10.0, leaves
everyone who followed the instructions on whichever build the closed
track was last given by hand (0.9.3, as it turned out, four releases
back). hack/store-status now says so in a line when the closed track
falls behind internal, because nothing else did.
Nothing about the APK path changes. F-Droid's recipe builds
assembleRelease and never sees fastlane/Fastfile, no Gradle
publishing plugin is applied, and the bundle is an extra output rather
than a replacement, which is also why the Play step in
.github/workflows/release.yml is continue-on-error and skips itself
entirely when the service account secret is absent. A fork, or a rejected
upload, must not be what makes a release fail. hack/release --no-play
turns that skip into something you can ask for: its annotated tag carries
release:no-play, and the workflow checks that marker before it considers
the stored credential.
The upload runs fastlane android internal with
SUPPLY_JSON_KEY pointing at a service account credential written to
$RUNNER_TEMP and removed by a trap in the same step. The credential is
android/google.play.service.serviceaccount in pass, shared with the
other apps on the account; it needs Release to testing tracks on Liseur
under Users and permissions.
A build that is already on internal, a release whose Play step was skipped, or one that predates the promotion, can be pushed across without rebuilding:
fastlane android promote version_code:19
The version code defaults to whatever app/build.gradle.kts currently
says, so the argument is only needed for an older build.
Who can actually install it. Everything above puts a build on a track; none of it lets anyone in. The tester list is console state, and these are the things that quietly turn a correct opt-in link into "this app isn't available":
- The closed
Testingtrack needs an email list attached, holding the Google account each tester uses on their phone.hack/store-statussays which of the two kinds of list the track is on, but it cannot say who is on an email list:edits.testersmodels only Google Groups, and its own reference says email lists are not supported by the resource. That address goes on by hand in the console, and nothing here can do it for you. - The console accepts either an email list or a Google Group, not both.
The console offers Email and Google Groups as one choice, not two, and
the API has only a
googleGroups[]field. Attaching a group cuts off every tester already on the email list until each of them joins it, which restarts the fourteen-day clock this whole exercise is running down. Two things are worth knowing before reaching for one anyway: the trap that made the group fail before is avoidable, because a consumer group's owner can directly add members rather than wait on join requests, but a group still buys no automation. Consumer@googlegroups.commembership has no API of any kind, Admin SDK and Cloud Identity both being Workspace-only, so it moves the clicking from one web UI to another. - Country availability is not the reason, and has not been for as
long as anyone has been asking. The track is open to 176 named
countries and to the rest of the world, so there is no country left
over to be missing;
hack/store-statusprints this outright so the question stops being guessed at. Russia is on the list and works: Google paused Play billing there in 2022 and stopped paying developers with Russian bank accounts in 2024, and both notices say in terms that free apps still download. Only North Korea and Syria are absent, which is Google's sanction policy rather than a switch in the console. This is track-level console state, and the release carries nocountryTargetingof its own, which is why asupplyupload has never disturbed it. - The track's release must be
completed, not draft.hack/store-statusprints the status of each one. - Adding an address is not instant. Play takes a few hours to propagate, and up to a day or two to move the opted-in count, so a tester told to try again should be told to wait.
- If the store still says the app is not available after all that, the usual cause is on the tester's side: the account signed into the Play Store on the phone is not the address that was added. A browser signed into a second Google account looks exactly the same.
The service account holds Release to testing tracks, which covers
everything in this repository: uploads, promotions, and every read
hack/store-status makes. It deliberately does not hold Manage testing
tracks and edit tester lists (CAN_MANAGE_TRACK_USERS_GLOBAL), the
permission edits.testers.update would need. Nothing automated has any
business rewriting who may install the app, and while the track runs off
an email list that call could not help anyway.
Only the changelog is pushed from the repository. The store listing is
edited in the console, because Play holds declarations that no file here
describes (data safety, content rating, target audience, app access, ads),
and those have to be revisited whenever the app gains a permission,
talks to something new, or changes what it stores. The privacy policy
Play links to is docs/PRIVACY.md, served by GitHub Pages from main
/docs; it is a published legal document, so change it in a commit and
not in the console.
Promotion out of testing and in front of the public is a manual action in the console, on purpose. Nothing in this repository can put a build on the production track.
The signing key. Play App Signing holds the same key as the GitHub
release, enrolled from pass through Google's PEPK tool rather than
generated by Google. That means a build installed from Play and a build
downloaded from the release page carry the same signature, so a reader
can move between them without uninstalling and losing their library. It
also means the key cannot be swapped without a rotation request. F-Droid
publishes two APKs per version, one with its own signature and one with
this same key grafted onto its reproduced build (see F-Droid readiness
below), so a reader can cross between all three channels.
Recreating the PEPK export, should it ever be needed:
d=$(mktemp -d) && umask 077
pass show android/liseur.keystore.p12 | base64 -d > "$d/release.p12"
java -jar pepk.jar --keystore="$d/release.p12" \
--alias="$(pass show android/liseur.keystore-alias)" \
--output="$d/output.zip" --include-cert \
--rsa-aes-encryption --encryption-key-path=encryption_public_key.pem
pepk.jar and encryption_public_key.pem are downloaded from the
console, and the temporary directory goes away afterwards.
The two edge-to-edge advisories
Every release raises the same pair of warnings on the Play Console, and neither is a defect report:
Edge-to-edge may not display for all users.
Your app uses deprecated APIs or parameters for edge-to-edge.
The first is attached to every app targeting SDK 35 or later. It asks us
to test, not to change anything. The app has drawn edge-to-edge since it
started targeting 35: MainActivity and ReaderActivity both call the
no-argument enableEdgeToEdge(), no theme sets android:statusBarColor
or android:navigationBarColor, and the manifest carries no opt-out.
The second is static analysis of the bundle's bytecode, and it does not say whose bytecode. Ours calls none of the deprecated setters. Every flagged reference belongs to androidx:
| Artifact | setStatusBarColor |
setNavigationBarColor |
setDecorFitsSystemWindows |
setSystemUiVisibility |
|---|---|---|---|---|
androidx.activity:activity |
4 | 4 | 4 | 0 |
androidx.core:core |
1 | 1 | 4 | 7 |
They are the version-guarded backports inside androidx's own
EdgeToEdge, WindowCompat and WindowInsetsControllerCompat, which
are the classes Google's own migration guide sends apps to. There is
nothing to migrate away from, and hand-rolling around them to quieten a
console page would be strictly worse. The warning stays until androidx
changes, so treat it as noise rather than as something to fix before a
release.
Re-check the claim, rather than trusting this table, if androidx moves:
activity_version=$(sed -n 's/^activityCompose = "\(.*\)"/\1/p' gradle/libs.versions.toml)
unzip -p "$(find ~/.gradle/caches/modules-2/files-2.1/androidx.activity/activity/"$activity_version" \
-name '*.aar' -print -quit)" classes.jar > /tmp/a.jar
unzip -p /tmp/a.jar '*.class' | grep -ao setStatusBarColor | wc -l
What the first advisory is good for is prompting an actual look. Doing
that once turned up two real inset bugs the console never mentioned:
the reader's search results were laid out 820px beneath the keyboard
where nothing could reach them, and neither the search nor the contents
screen kept clear of a display cutout in landscape. Both screens pinned
contentWindowInsets to WindowInsets.systemBars, which omits the IME
and the cutout; safeDrawing covers all three. An emulator with a cutout
is the way to see it:
adb shell cmd overlay enable com.android.internal.display.cutout.emulation.tall
adb reboot # the display picks the cutout up on boot, not on enable
Release notes
Two things are written for every release, and they are not the same thing:
- The F-Droid changelog is
fastlane/metadata/android/en-US/changelogs/<versionCode>.txt. Written by hand, capped at 500 characters, and passed tohack/releaseas the release notes argument. This is what F-Droid shows. - The GitHub release notes are generated during the release by
hack/generate-release-notes. It asks Gemini to turn the commits since the previous tag into something a reader would want to read, using the hand-written changelog as the summary to lead with. It also gives Gemini the associated pull-request descriptions, links changes back to those PRs, and carries a relevant screenshot over from a PR body when one is available. Those screenshots are re-sized to a fixed width (SCREENSHOT_WIDTHin the script) before the notes are written, so a full-size PR image cannot blow the page apart.
Without GEMINI_API_KEY, or if the request fails, the generated notes
fall back to the hand-written changelog, so a release is never held up
by this. The key comes from pass under google/gemini-api and is
uploaded to the release environment by hack/release --sync-secrets.
The notes can be rewritten after the fact: the body of a release stays editable even though its tag and assets do not:
GEMINI_API_KEY=$(pass show google/gemini-api) \
hack/generate-release-notes --output notes.md v0.2.0
gh release edit v0.2.0 --notes-file notes.md
Never delete a final release or release tag
Releases are immutable once published, and that goes further than the assets: a tag that has carried a release can never be used again, even after deleting both the release and the tag, and even with the feature turned off. GitHub does this so that a trusted artifact can never be swapped for another under the same name.
So a final release that went out wrong is not fixed by deleting it. Bump
the patch version and release again. v0.1.0 was burned exactly this way
and is why the first published version is 0.1.1.
The deliberate exception is the GitHub release record for a prerelease.
hack/prune-prereleases removes obsolete RC pages and their APK assets to
keep the releases list useful, but never removes the tag. The name remains
burned and the source commit remains reachable, which is also why neither
the script nor the workflow ever uses gh release delete --cleanup-tag.
What F-Droid checks
Updating the submission is not the end of it. Pushing to the metadata
merge request starts a pipeline on fdroiddata, and that pipeline can
fail long after hack/release has finished and reported success.
The check that catches people out is fdroid rewritemeta. It reformats
metadata/com.chmouel.liseur.yml and fails if the result differs from
what is committed, byte for byte, trailing newline included. It is not a
linter with opinions to argue with: the file has to be what it would
have written.
Every release for a month failed this job while the builds themselves
passed. hack/release had been sending the metadata through jq as
--arg content "$(cat file)", and command substitution strips trailing
newlines, so what arrived ended mid-line. Use jq --rawfile, which
reads the file as it is. The same trap is waiting in any script that
sends a file through a JSON API.
hack/release now waits for the metadata checks and stops the release
naming whatever failed. What it does not wait for is fdroid build,
which compiles the app from source, and the check apk that follows it:
about twenty-five minutes between them. Those are left running and the
pipeline is linked in the output, so look at it before assuming a
release landed.
A failure in fdroid build usually means reproducibility, which
hack/verify-reproducible will reproduce locally. See F-Droid
readiness below.
Store assets
The screenshots and the icon are regenerated rather than maintained by hand:
hack/screenshots --setup # build the demo shelf first, then capture
hack/screenshots # capture from a device already set up
hack/screenshots --setup-only # build the shelf and stop, to check it
hack/screenshots --class tablet # file the captures as a tablet set
hack/screenshots --no-dictionary # skip the one capture that needs the network
hack/icon # fastlane icon.png, from the vector drawables
hack/feature-graphic # fastlane featureGraphic.png, from the brand emblem
hack/screenshots drives a connected device through adb and writes both
docs/screenshots and the matching fastlane directory. It finds controls
by what they say rather than by where they sat when it was written, so a
moved button is something it waits for and fails on, not a tap into empty
space.
There are two sets, because fastlane and F-Droid publish
phoneScreenshots and tenInchScreenshots separately and the README
embeds the phone set by name. Which one a run writes is decided by how
wide the device is (under 600dp phone, above it tablet), so a capture
against a tablet cannot quietly overwrite the phone images. Pass
--class for a device whose shape does not match how its pictures
should be filed.
The phone set is the full tour, seventeen screens, gathered on
docs/SCREENSHOTS.md; the README shows three of them. The tablet set is
three pictures of what a phone cannot show (two columns, the control
that chooses them, and a shelf with room on it) and lands in
docs/screenshots/tablet. There is no point photographing the settings
screen twice, and the search and dictionary steps that make the phone
run slow are skipped, so a tablet run takes a few minutes.
--setup builds the shelf from nothing: it downloads a handful of
Standard Ebooks public domain editions
(their cover art is what makes the library screens publishable), pushes
them to the device, grants the folder through the real picker, and leaves
the first book part-read with three highlights, a note and a bookmark on
it. Each of those is checked against the database afterwards, because a
highlight that quietly failed looks exactly like one that worked. One of
the three marks runs down the page rather than along a line, because the
notebook only offers to open a mark out once it is longer than four
lines. --setup also writes six weeks of sittings into
reading_sessions with run-as sqlite3: the stats screens count from
the moment a book is first opened, and a shelf seeded an hour ago has
nothing on them. Run it once; later runs can drop --setup and take
about ten minutes.
The definition card is the one capture that needs the device to reach
something: the run turns the lookup on in Settings, presses words until
one comes back with senses on it, and puts the switch back where it found
it. It used to keep the previous image and carry on when nothing came
back, which is how a stale picture survived several releases. It now
fails. --no-dictionary is how to say an offline run was expected.
--empty is its own mode, and short: the empty library is the one screen
the tour cannot reach, because the tour needs a shelf with books on it.
It wipes app storage, photographs what a new reader sees, and stops. Run
it whenever the empty state changes; the rest of the tour would only put
the demo shelf back.
Everything that gets published is in the light theme. A dark screenshot
in a store listing reads as the app looking like that, rather than as the
app being able to; the dark theme earns more as a line in the description
than as one picture in six that matches none of the others. The script
still captures 11-reading-dark and 17-empty-library-dark, which are
useful to look at, but it just does not file them with fastlane or the
README.
Sign out of calibre-web first unless your server holds only books you would publish a picture of. The script itself never writes to one.
Look at the images before committing. A screen can be found and still be showing the wrong thing:
montage docs/screenshots/*.png -tile 6x2 -geometry 320x+6+6 /tmp/sheet.png
Architecture
See AGENTS.md for the layered package layout and project conventions.
Key decisions:
- Readium Kotlin Toolkit (
readium-shared,readium-streamer,readium-navigator,readium-opds) does EPUB parsing, rendering, and OPDS feed parsing.readium-lcpis deliberately excluded, since it depends on the proprietary liblcp, incompatible with F-Droid. - calibre-web integration is two protocols: OPDS for browse/search/
download, and the Kobo sync protocol (
/kobo/<token>/v1/...) for reading-position sync, exchanging percentage progression like KOReader does. - Komga integration is Komga's own REST API:
POST /api/v1/books/listto browse,GET /api/v1/books/{id}/fileto download, andGET/PUT /api/v1/books/{id}/progressionto sync a full Readium locator rather than a percentage. - One server is connected at a time.
data/remote/holds provider-neutral contracts (CatalogSource,FileSource,ServerSetup,PositionSync);data/calibre/anddata/komga/implement them, andRemoteRouterpicks the implementation from the connected server'sServerKind.domain/ReadingStateMerge.ktis shared by both, so the conflict rules are written once. - Single
:appmodule, manual DI composition root,ViewModel+StateFlow, Room + DataStore for persistence.
calibre-web protocols
Verified against a real calibre-web install (behind Caddy + Cloudflare) and
against the calibre-web source (cps/opds.py, cps/kobo.py,
cps/kobo_auth.py, cps/services/SyncToken.py).
OPDS catalog
-
OPDS 1.2, Atom XML, served under
/opds(application/atom+xml;profile=opds-catalog). -
Auth is HTTP Basic on every route; a 401 carries
WWW-Authenticate: Basic realm="Authentication Required". Anonymous browsing is a server-side option. -
Navigation feeds:
/opds/new,/opds/discover,/opds/rated,/opds/hot,/opds/author,/opds/publisher,/opds/category,/opds/series,/opds/ratings,/opds/formats,/opds/language,/opds/shelfindex,/opds/readbooks,/opds/unreadbooks. -
/opds/booksis a letter index; the full list is/opds/books/letter/00. -
Paging is
?offset=N, 60 entries per page, withrel="next"andrel="prev"links. Search results are not paged. -
Search is
/opds/search/{terms}or/opds/search?query={terms}; the OpenSearch description is at/opds/osd. -
An acquisition entry carries the calibre UUID as
<id>urn:uuid:...</id>, covers as/opds/cover/<int id>(integer, not UUID) and a download link:<link rel="http://opds-spec.org/acquisition" href="/opds/download/74/epub/" length="156172" title="EPUB" mtime="2026-07-26T10:26:49+00:00" type="application/epub+zip"/> -
Downloads honour
Range(206) and sendETag,Last-Modifiedand a UTF-8Content-Dispositionfilename, so resumable downloads work. -
A user without the "Allow Downloads" permission gets 401 on
/opds/download/...(403 on the web UI route) while browsing keeps working. The app must recognise that case and tell the user to enable that permission for their account in calibre-web, rather than showing a generic failure.
Kobo sync
Off by default; the admin enables it in Feature Configuration, then each
user creates a token from their profile page ("Kobo Sync Token"), which
yields a base URL of the shape https://host/kobo/<32 hex chars>. The
token is the only credential; it never expires and there is no user
agent or device check.
-
GET /v1/library/syncreturns a JSON array of entities:NewEntitlement,ChangedEntitlement,ChangedReadingState,DeletedTag, ... Each entitlement bundlesBookEntitlement,BookMetadataandReadingState. -
Paging is via the
x-kobo-synctokenheader (base64 JSON of per-table timestamps); the response repeats it, and sendsx-kobo-sync: continuewhen more pages remain. Sending the previous token returns only what changed since. -
GET|PUT /v1/library/<uuid>/statereads and writes the reading position. The PUT handler indexes its keys directly, soCurrentBookmark,StatisticsandStatusInfomust all be present (any of them may benull); omitting one returns 400:{"ReadingStates": [{ "CurrentBookmark": {"ProgressPercent": 42, "ContentSourceProgressPercent": 42, "Location": null}, "Statistics": null, "StatusInfo": {"Status": "Reading"}}]}Status is
ReadyToRead,ReadingorFinished. Writes are last-write-wins, with no conflict detection, so the client comparesLastModifieditself. AnullLocationleaves the stored location untouched rather than clearing it. -
Position mapping:
ProgressPercent / 100maps to a ReadiumLocator.locations.totalProgression.Location.Valueis a kepub span id (kobo.7.1) that has no Readium equivalent, so it is read but not written. -
Books are offered as KEPUB when the server has
kepubifyconfigured (DownloadUrlsthen lists KEPUB only). KEPUB is EPUB3 with extra spans, and Readium opens it fine. -
Behind a reverse proxy, download URLs are built from the forwarded host; a Cloudflare/Caddy install can still emit
http://URLs, so the client must rewrite the scheme to match the configured base URL. -
Deletions in calibre are not propagated (only archived books are, as
IsRemoved), and users can restrict syncing to selected shelves, which makes an empty sync legitimate.
Komga protocol
Verified against a real Komga install (API 1.25.0) with an API key, and
against BookLifecycle.kt on master. Every rejection below was provoked
deliberately rather than read off the schema.
- Auth is
X-API-Key: <key>, created in Komga's web UI. Identity isGET /api/v2/users/me->{id, email, roles[]}; there is no/api/v1/users/me.FILE_DOWNLOADinrolesmeans the account may download. Users paste the API-key page address, so setup reduces a pasted URL to its origin. - Browse is
POST /api/v1/books/list?page=&size=&sort=with an EPUB + READY condition;GET /api/v1/booksis deprecated since 1.19.0. Each entry carriesreadProgress {page, completed, readDate}inline, which is the change detector: a routine sync costs one request. - Search is the same endpoint with
fullTextSearchset, a sibling ofcondition. PUT /progressionvalidates, in order:modifiedstrictly after the storedreadDate(else409);href, fragment stripped and URL-decoded, exactly matching an internal EPUB file name, with no leading slash (else400); andlocations.progression, which is required. OurtotalProgressionis ignored and recomputed.- There is no page-based fallback.
PATCH read-progress {"page": N}is rejected for reflowable EPUB ("not Divina compatible"); only{"completed": true}works. On a400the client instead fetchesGET /api/v1/books/{id}/positionsand snaps to the nearest position Komga already knows, keeping the position rather than coarsening it. That index is ~330 KB for an ordinary book, so it is only ever fetched after a rejection. 409is not a failure. The server holds something at least as new; the row stays dirty and the next run pulls and reconciles.GET /progressionanswers204with an empty body when there is no progress. A404means the book is unknown, and is a failure.modifiedcomes back with a local UTC offset and is double-offset, so it must not be used for ordering;readProgress.readDateround-trips exactly and is what the sync orders by.- Deleting a book from the server is admin-only
(
DELETE /books/{id}/file), so that action is hidden for Komga.
liseur-sync protocol
A second kind of partner, and a different shape from the two above.
calibre-web and Komga each hold one current position per book and
answer "where am I"; liseur-sync holds an append-only log and answers
"what has happened since seq". It holds no books at all, which is
what lets it sync a book that came off an SD card.
- Auth is
Authorization: Bearer <token>.POST /v1/loginwith a username and password mints device tokens throughPOST /v1/tokens; one scope per token, so signing in asks for two,syncandread-insights, and a reader who pastes a token made elsewhere usually has only the first. Statistics are simply absent then. - The cursor is the only irreplaceable state. Everything else can
be asked for again, but ops behind
sync_account.cursor_seqcannot. So a page fromGET /v1/changes?since=is written and the cursor advanced in one transaction, never the other way round. A cursor that has fallen below the server's compaction horizon gets410 resync_required; the answer isGET /v1/headsand itssnapshot_seq. - Ids are derived, not drawn. The server treats
op_idandsession_idas idempotency keys and compares the whole payload behind each: same id and same payload isduplicate, same id and a different payload is a conflict. So the id isUUIDv3(deviceKey|workId|revision)and every payload field comes from stored state:client_tsisreading_progress.updated_at, never the clock. A push interrupted by a dead network is simply repeated. The server does not check that the id is a UUIDv7; it is opaque, up to 64 characters. POST /v1/works/resolvetakes every identifier at once (sha256:,pmd5:(KOReader's partial MD5),dc:andta:) and registers all of them against whichever matched, which is how a re-encoded copy and the original converge.409means they named two different works; the server changes nothing and merging is left to the reader.ta:normalisation is an interoperability contract and is not in the schema. The server matchestaaliases by exact string and computes nothing itself.WorkIdentifiers.titleAuthordefines it asfold(title)|fold(author), where folding is NFKD, strip\p{Mn}, lowercase, non-alphanumerics collapsed to single spaces, trimmed. Any other client must agree exactly or it will silently fail to match.- A locator over 16 KB is dropped and the progression sent on its own. Failing the push outright would leave the other device with no idea where the reader is, which is far worse than reopening at a percentage.
- A book resolved today has all of its history behind the cursor, so
a newly named book is seeded once from
GET /v1/works/{id}/positions?limit=1. POST /v1/sessionstakes closed sessions only, as progression fractions. Never send page numbers: a page is a property of one rendering of one edition at one type size, and the server derives pages itself when it knows the edition.idle_msis always zero here and honestly so: time is counted only while the reader is in the foreground, so time spent elsewhere is already absent rather than included and subtracted.- Statistics (
/v1/insights/*) are decoration. Every failure is null and silent, and a nulleta_secondsis carried through untouched: no estimate beats an invented one. - Uploading is opt-in twice over. The server advertises the
library-uploadscope onGET /v1/tokenand marks the folders that take uploads inGET /v1/folders; without both, the action is not offered at all, which is how an older server needs no version check.POST /v1/folders/{folder}/booksismultipart/form-data, keyed by the file's SHA-256, so a repeated upload answers200 duplicateand stores nothing twice.202means the bytes are safe but the server had not catalogued them yet; the worker simply asks again, and the digest makes the second ask free. - What follows an upload is adoption, not replacement. The local row
keeps its own
urland gainsremote_uuidanddownload_href(BookDao.linkToRemote). Rewriting the URL to the server's spelling would take every reading position, annotation and session with it. Because the catalog reads what the library holds once, before its walk begins, both sides guard against the book being introduced twice: the catalog re-asks about the ids on a page it has not accounted for, and adoption drops a catalog row that got there first.
F-Droid readiness
-
Dependencies are all FOSS, from Maven Central or Google's Maven. In particular
readium-lcpis deliberately absent: it pulls in the proprietary liblcp. The list users see is inLicencesScreen.kt. -
No trackers or analytics, and no Google Play services. The only outbound traffic is to the calibre-web or Komga server the user configured, and to a dictionary site when a definition is asked for. That second one is off until switched on in Settings and the site is the user's to choose (
DictionaryUrl), because F-Droid review will otherwise treat a hardcoded third-party host as grounds for the TetheredNet anti-feature. Together those justifyINTERNET;ACCESS_NETWORK_STATEis there for theNetworkType.CONNECTEDconstraint on the sync workers. -
No non-free assets. The bundled fonts (Literata, Vollkorn, Atkinson Hyperlegible, Inter) are all OFL; the icon is drawn in-repo as vector drawables.
-
fonts.googleapis.comappears in the release dex and is unreachable. It is a string inside Readium'sReadiumCss, emitted only for families registered throughEpubNavigatorFactory's separategoogleFontslist. Liseur never sets that list: everyaddFontFamilyDeclarationinReaderPreferencesMapper.ktsources its faces from bundled asset paths. Worth knowing, because a reviewer grepping the dex for hosts will find it and ask. -
Reproducible versioning:
versionCodeandversionNameonly ever change in achore: release vX.Y.Zcommit made byhack/release, and every release is tagged. F-Droid'sUpdateCheckMode: Tagsstill notices a new tag, butAutoUpdateModeisNone: under dual signing (below) every release needs its extracted signature delivered by hand, which their bot cannot do yet, sohack/releaseopens that merge request itself. -
Metadata lives in the repo under
fastlane/metadata/android/en-US/: title, descriptions, per-versionCode changelogs, icon and screenshots. -
The build needs no network beyond Gradle dependencies and no signing config:
assembleReleaseon a clean checkout produces an unsigned APK, which is what F-Droid builds and signs itself. -
The build is reproducible. F-Droid rebuilds from source and will not publish a build it cannot reproduce, so
hack/verify-reproduciblebuilds the release APK twice from two clean checkouts at deliberately different paths and compares the two byte for byte:hack/verify-reproducible # HEAD hack/verify-reproducible v0.2.0 # a tag, before submitting itIf they differ it names the entries responsible, which is usually a timestamp baked into a resource or an absolute build path that leaked in.
hack/releaseruns it on every release commit and undoes the commit rather than tag a build F-Droid could never publish; run it yourself before anything unusual ships. Movekeystore.propertiesaside first if you have one: the check compares the unsigned APK.Two build-file settings exist purely to keep this true and must not be removed:
dependenciesInfois switched off (AGP would otherwise embed a dependency manifest encrypted for Google Play, which nobody else can reproduce), andpackaging.jniLibs.keepDebugSymbolscovers every.so(the only native code arrives prebuilt in AndroidX AARs; re-stripping it ties the bytes to the build machine's NDK: this was the one thing that made the CI APK differ from a local rebuild). -
Publishing our own signature: dual signing (merged 2026-08-21). Because the build is reproducible, F-Droid can publish the developer signature. Replacing their signature outright was declined in review, since existing F-Droid installs carry F-Droid's key and Android would refuse them every further update, so the app uses F-Droid's dual-signing flow instead: for each version F-Droid publishes two APKs, one signed with its own key (existing users keep updating, nothing breaks) and one carrying our signature, grafted onto F-Droid's own rebuild after that rebuild comes out byte-identical with ours. New installs from F-Droid get the developer-signed copy, which is interchangeable with the GitHub release. Concretely, the metadata has:
AllowedAPKSigningKeys:with two SHA-256 digests: our certificate and the key F-Droid signs this app with (their reviewer added the second; both APKs must pass the check).AutoUpdateMode: None: their bot cannot drive this flow, so every release must arrive as a merge request adding the new build entry plus the signature files undermetadata/com.chmouel.liseur/signatures/<versionCode>/, extracted from the signed release APK withfdroid signatures <apk>.hack/releasedoes all of this: it downloads the APK from the GitHub release, extracts the signature, appends the build entry, normalises the file withfdroid rewritemeta, and opens (or, when one is already open, updates) the fdroiddata merge request. It needsfdroidserverinstalled (pipx install fdroidserver, or pointFDROID_BINat one); without it the release still goes out andhack/release --fdroid-only VERSIONfinishes the F-Droid part later.
The merge request that set this up was fdroiddata!46390 (merged); the first verifiable tag is v0.9.3 (versionCode 17), because reproducibility landed after the v0.9.2 tag, so only 17 and later get the developer-signed twin. NewPipe (fdroiddata!46133) is the worked example this follows.
-
Checking where things stand.
hack/store-statusprints the published versions, the index age, what the last build run did with the app, the upstream metadata, and any open merge request with its pipeline state, alongside the GitHub releases and the Play tracks, so one command answers what each of the three channels is showing. -
Submitted. The inclusion merge request was fdroiddata!44292 (merged), and
hack/releaseopens the per-release signature merge request described above. See What F-Droid checks above for what its pipeline runs and how it can fail after a release looks finished.