diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2b6c245c..5efbcedf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ permissions: jobs: release: - name: Build signed APK + name: Build and publish the release runs-on: ubuntu-latest environment: release timeout-minutes: 30 @@ -106,3 +106,45 @@ jobs: --title "Liseur $RELEASE_TAG" \ --notes-file "$RELEASE_NOTES" \ "$RELEASE_APK" + + # Everything below is Google Play, and only Google Play. It runs + # after the release above on purpose: the GitHub artefact and the + # F-Droid submission are the primary channels, so a rejected upload + # here must never be what makes a release fail. + - name: Check for the Play credential + id: play + env: + SERVICE_ACCOUNT: ${{ secrets.LISEUR_PLAY_SERVICE_ACCOUNT_JSON }} + run: | + set -euo pipefail + if [[ -n "$SERVICE_ACCOUNT" ]]; then + printf 'enabled=true\n' >> "$GITHUB_OUTPUT" + else + printf 'enabled=false\n' >> "$GITHUB_OUTPUT" + printf 'No Play service account configured; skipping.\n' + fi + + - name: Build the release bundle + if: steps.play.outputs.enabled == 'true' + run: ./gradlew bundleRelease --console=plain + + - name: Set up Ruby + if: steps.play.outputs.enabled == 'true' + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: "3.3" + + - name: Upload to the Play internal track + if: steps.play.outputs.enabled == 'true' + continue-on-error: true + env: + SERVICE_ACCOUNT: ${{ secrets.LISEUR_PLAY_SERVICE_ACCOUNT_JSON }} + run: | + set -euo pipefail + key="$RUNNER_TEMP/play-service-account.json" + # The trap covers the failure paths too: the credential must not + # outlive this step whatever fastlane makes of it. + trap 'rm -f "$key"' EXIT + (umask 077 && printf '%s' "$SERVICE_ACCOUNT" > "$key") + gem install fastlane -v 2.238.0 --no-document + SUPPLY_JSON_KEY="$key" fastlane android internal diff --git a/.gitignore b/.gitignore index 7b570be5..1bc554ab 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,9 @@ keystore.properties *.jks *.keystore *.p12 + +# fastlane writes these next to the configuration it is given. +fastlane/report.xml +fastlane/README.md +fastlane/Preview.html +fastlane/play-service-account.json diff --git a/DEVELOPER.md b/DEVELOPER.md index 75f09cf3..5958190d 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -15,9 +15,11 @@ Manager package list). ```bash ./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,release}/`. +Output APKs land in `app/build/outputs/apk/{debug,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 @@ -181,7 +183,8 @@ 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. That environment must hold `LISEUR_KEYSTORE_BASE64`, `LISEUR_KEYSTORE_PASSWORD`, -`LISEUR_KEY_ALIAS` and `LISEUR_KEY_PASSWORD`; `hack/release` creates the +`LISEUR_KEY_ALIAS`, `LISEUR_KEY_PASSWORD` and +`LISEUR_PLAY_SERVICE_ACCOUNT_JSON`; `hack/release` creates the environment and uploads whichever of them 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: @@ -193,6 +196,68 @@ hack/release --sync-secrets Signing a build on your own machine is a separate matter, covered under [Signing a release build](#signing-a-release-build-optional) above. +### 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: + +```bash +make bundle # ./gradlew bundleRelease, for Play only +``` + +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. + +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. + +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 internal testing is a manual action in the console, on +purpose. Nothing in this repository can put a build in front of the +public. + +**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 +signs with its own key unless reproducible builds are configured, so that +third artefact is unaffected either way. + +Recreating the PEPK export, should it ever be needed: + +```bash +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. + ### Release notes Two things are written for every release, and they are not the same diff --git a/Makefile b/Makefile index c4557641..a53df68f 100644 --- a/Makefile +++ b/Makefile @@ -12,12 +12,13 @@ PACKAGE := com.chmouel.liseur ACTIVITY := $(PACKAGE)/.MainActivity DEBUG_APK := app/build/outputs/apk/debug/app-debug.apk -.PHONY: help build debug release test lint check clean emulator stop shutdown install run run-bg reset screenshots icon feature-graphic +.PHONY: help build debug release bundle test lint check clean emulator stop shutdown install run run-bg reset screenshots icon feature-graphic help: @printf '%s\n' \ 'make build Build the debug APK' \ 'make release Build the release APK' \ + 'make bundle Build the release AAB for Google Play' \ 'make test Run JVM unit tests' \ 'make lint Run Android Lint' \ 'make check Run tests, lint, and debug build' \ @@ -41,6 +42,11 @@ build debug: release: $(GRADLE) assembleRelease +# Google Play only. F-Droid and the GitHub release both build the APK +# above, so this target is additive and nothing else depends on it. +bundle: + $(GRADLE) bundleRelease + test: $(GRADLE) testDebugUnitTest diff --git a/README.md b/README.md index 91eef68a..68bddff4 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,8 @@ When you switch between devices, you have the choice to have your place travel w Everything runs privately and without distraction. There are no trackers, no analytics, no ads, and no attempts to sell you a monthly subscription for books you already own. Liseur only talks to the book servers and dictionary sources -you choose to add. +you choose to add. The [privacy policy](https://chmouel.github.io/liseur/PRIVACY) +spells out exactly what that means. ## Install diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md new file mode 100644 index 00000000..30211031 --- /dev/null +++ b/docs/PRIVACY.md @@ -0,0 +1,100 @@ +--- +title: Liseur Privacy Policy +--- + +# Privacy Policy + +**App:** Liseur (`com.chmouel.liseur`) +**Developer:** Chmouel Boudjnah +**Last updated:** 19 August 2026 + +Liseur is an open-source ebook reader. It has no account, no advertising, +no analytics and no trackers, and it collects nothing about you. Its +source code is public at +, so every claim on this page can be +checked rather than taken on trust. + +## What Liseur collects + +Nothing. There is no server run by the developer, no crash reporting, no +usage measurement, no advertising identifier, and no account to sign up +for. No data leaves your device except to the servers described below, +which are ones you choose and enter yourself. + +## What Liseur stores on your device + +All of this stays in the app's own private storage: + +- Your library: the books you added, their metadata and their covers. +- Where you are in each book, along with your reading history and the + time you have spent reading. +- Your highlights, notes and bookmarks. +- Your reading preferences: theme, font, size, spacing, margins. +- If you connect a book server, its address and the credentials or token + it issued. Those are encrypted with a key held in the Android Keystore, + which cannot be exported from the device. + +Uninstalling the app removes all of it. + +Liseur reads EPUB files from a folder you pick through Android's own +document picker. It only ever sees the folder you granted, and it does +not copy your books anywhere. + +## Network access + +Liseur talks to exactly two kinds of address, both of which are yours to +choose: + +**Your book server.** If, and only if, you connect one, Liseur talks to +the calibre-web, Komga or liseur-sync server whose address you typed in. +It sends the credentials that server asked for, downloads the books and +covers you request, and exchanges your reading position so the same book +resumes in the right place on another device. That server is operated by +you or by whoever you chose to trust; this policy does not cover what it +does with the data it receives. + +**A dictionary site, if you enable it.** Looking a word up online is off +by default. When you switch it on, Liseur sends the single selected word +to the dictionary site configured in Settings — by default the public +Wiktionary API — over HTTPS. No identifier, no book title and no account +accompanies it. You may point this at any Wiktionary edition or mirror, +or leave the feature off and hand words to an offline dictionary app +installed on your device instead. + +Liseur never contacts any other host. It requests the `INTERNET` and +`ACCESS_NETWORK_STATE` permissions for the two purposes above and for +nothing else. + +## Android backup + +Liseur takes part in Android's standard backup, so your library, reading +positions, highlights, notes and settings can follow you to a new device. +That backup is handled by Android and stored in your own Google account, +under Google's terms, not the developer's — Liseur has no access to it. +Downloaded book files and generated covers are deliberately excluded. +Server credentials are included but arrive unreadable on a new device, +because the key that encrypts them never leaves the old one; Liseur +notices this and asks you to sign in again. + +You can turn this off in your device's backup settings. + +## Children + +Liseur is not directed at children and collects no personal information +from anyone, of any age. + +## Sharing + +Nothing is shared or sold, because nothing is collected. The developer +receives no data from the app whatsoever. + +## Changes + +Any change to this policy will be published on this page with a new date +above, and its history is visible in the repository. + +## Contact + +Questions, or a problem with this policy: open an issue at + or write to +. diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 00000000..581a8cec --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,4 @@ +theme: jekyll-theme-minimal +title: Liseur +description: An open-source ebook reader for Android +show_downloads: false diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 00000000..9f0a2c84 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,2 @@ +json_key_file(ENV["SUPPLY_JSON_KEY"].to_s) +package_name("com.chmouel.liseur") diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 00000000..09a8285c --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,36 @@ +# Google Play uploads only. Nothing here is part of building Liseur: +# F-Droid and the GitHub release both build the APK with plain Gradle and +# never see this directory. +# +# The metadata under fastlane/metadata/android is written for F-Droid and +# happens to be supply's native layout, so Play reads the same files. Only +# the changelog is pushed from here: the store listing itself is edited in +# the console, because Play holds the declarations (data safety, content +# rating, target audience) that no file in this repository describes, and +# an accidental metadata push is a listing change under review. + +default_platform(:android) + +platform :android do + desc "Upload the release bundle to the Play internal testing track" + lane :internal do + bundle = "app/build/outputs/bundle/release/app-release.aab" + UI.user_error!("no bundle at #{bundle} — run ./gradlew bundleRelease") unless File.exist?(bundle) + + upload_to_play_store( + track: "internal", + aab: bundle, + # A track upload is a release; the APK path belongs to GitHub. + skip_upload_apk: true, + skip_upload_metadata: true, + skip_upload_images: true, + skip_upload_screenshots: true, + skip_upload_changelogs: false, + # The first upload of all is done by hand, because enrolling the + # signing key happens inside the console's release wizard. Every + # upload after that goes straight to the testers. + release_status: "completed", + validate_only: ENV["PLAY_VALIDATE_ONLY"] == "true" + ) + end +end diff --git a/hack/release b/hack/release index 3bd284a2..28eac54d 100755 --- a/hack/release +++ b/hack/release @@ -18,6 +18,7 @@ readonly PASS_KEYSTORE="android/liseur.keystore.p12" readonly PASS_KEYSTORE_PASSWORD="android/liseur.keystore-password" readonly PASS_KEY_ALIAS="android/liseur.keystore-alias" readonly PASS_GEMINI_KEY="google/gemini-api" +readonly PASS_PLAY_SERVICE_ACCOUNT="android/google.play.service.serviceaccount" readonly NOTES_MODEL="gemini-3.6-flash" readonly REQUIRED_SECRETS=( LISEUR_KEYSTORE_BASE64 @@ -25,6 +26,7 @@ readonly REQUIRED_SECRETS=( LISEUR_KEY_ALIAS LISEUR_KEY_PASSWORD GEMINI_API_KEY + LISEUR_PLAY_SERVICE_ACCOUNT_JSON ) usage() { @@ -51,7 +53,8 @@ release wants --no-fdroid. Once the inclusion request is merged, run hack/release --fdroid-only VERSION to catch that release up. The signing key lives in pass, under android/liseur.keystore.p12, -android/liseur.keystore-password and android/liseur.keystore-alias. Any +android/liseur.keystore-password and android/liseur.keystore-alias, and the +Play upload credential under android/google.play.service.serviceaccount. Any release secret missing from the GitHub release environment is uploaded from there, so a fresh clone needs nothing but an unlocked password store. EOF @@ -132,6 +135,8 @@ sync_release_secrets() { set_release_secret LISEUR_KEY_PASSWORD \ "$(pass_show "$PASS_KEYSTORE_PASSWORD")" set_release_secret GEMINI_API_KEY "$(pass_show "$PASS_GEMINI_KEY")" + set_release_secret LISEUR_PLAY_SERVICE_ACCOUNT_JSON \ + "$(pass_show "$PASS_PLAY_SERVICE_ACCOUNT")" } github_release_is_published() {