Development
Project Setup
To run the project, open it in the latest Version of Android Studio and build the project as usual.
The default local development variant is freeDebug, which does not require Google services credentials.
Play variants include Firebase, Crashlytics, Remote Config, and Play review dependencies.
Play builds require app/src/play/google-services.json; CI writes this file from the release secret.
By default, there is not enough memory configured for gradle. You can fix this by running.
This will configure your global ~/.gradle/gradle.properties to use more memory, depending on your machine.
Example of the generated properties, check the gradle_bootstrap.sh script for exact details.
# Begin: Gradle JVM bootstrap-generated properties
org.gradle.jvmargs=-Dfile.encoding=UTF-8 -XX:+ExitOnOutOfMemoryError -Xms4g -Xmx16g
kotlin.daemon.jvm.options=-Dfile.encoding=UTF-8 -XX:+ExitOnOutOfMemoryError -Xms4g -Xmx16g
# End: Gradle JVM bootstrap-generated properties
Tests
Unit tests
To run the unit tests, run the following command:
Instrumentation tests
To run the instrumentation tests, run the following command:
Developer Menu
The developer menu is hidden by default.
To unlock it:
- Open
Settings. - Tap the app version entry 13 times.
- Open the newly visible developer menu from
Settings.
Opening a Screen Directly
Debug builds can start on any back stack of Destination.Composes, passed as JSON in the debugBackStack extra.
The emulator skill's script builds it for you:
ANDROID_SERIAL=<serial from adb devices> .agents/skills/emulator/scripts/ui.py open BookOverview Settings DeveloperSettings
Agents use this through the emulator skill in .agents/skills/emulator, which also gives every agent its own emulator.
Ktlint
Voice uses Ktlint to enforce consistent code formatting.
- Check for formatting issues:
- Auto-fix formatting:
- To make commits fail on formatting errors, set up a pre-commit hook:
Releasing
To release a new version, trigger the Release Workflow
This creates a draft GitHub release with the APK and keeps the Play AAB as a workflow artifact. With submit_to_stores enabled,
it also uploads the AAB to the Play Store’s internal track and promotes it to a 0.1% production rollout.
The release workflow uses the protected release GitHub environment. CI signs through Gradle's injected signing properties,
keeps keystores as base64 file secrets, and passes store passwords, key aliases, and key passwords as environment variables;
no checked-in or generated signing properties files are required.
The release environment requires these secrets:
| Secret | Purpose |
|---|---|
PLAY_SERVICE_ACCOUNT |
Google Play service account JSON, base64 encoded. |
GOOGLE_SERVICES |
Play flavor google-services.json, base64 encoded. |
PLAY_SIGNING_KEYSTORE |
Play release keystore, base64 encoded. |
PLAY_SIGNING_STORE_PASSWORD |
Play release keystore password. |
PLAY_SIGNING_KEY_ALIAS |
Play release key alias. |
PLAY_SIGNING_KEY_PASSWORD |
Play release key password. |
GH_SIGNING_KEYSTORE |
GitHub/free release keystore, base64 encoded. |
GH_SIGNING_STORE_PASSWORD |
GitHub/free release keystore password. |
GH_SIGNING_KEY_ALIAS |
GitHub/free release key alias. |
GH_SIGNING_KEY_PASSWORD |
GitHub/free release key password. |
FIREBASE_APP_DISTRIBUTION_SERVICE_ACCOUNT |
Firebase App Distribution service account JSON, base64 encoded. |
Releasing to F-Droid, IzzyDroid and GitHub
The draft gets its git tag only when it's published. F-Droid builds every tag that matches ^[\d.]+-\d+$, usually a few
days later, and IzzyDroid picks up the APK from the published release. Neither has a staged rollout, so publish the
draft only once the Play rollout has reached 100%.
A version that turns out broken on Play is never published. Keep its draft until the next release is published: the next version is counted from the tags and the drafts, so deleting it earlier hands its version code out again, which Play rejects.
Checking on F-Droid
F-Droid takes a release in three steps:
- Picked up: the checkupdates bot adds the new tag to
Voice's build recipe with
the commit
bot: Update Voice to <versionCode>. It only takes the newest tag, so a release that already has a successor when the bot runs is never built. - Built: the last build cycle shows when it ran and lists the
successful and failed builds. The build log of a version, failed or not, is at
https://monitor.f-droid.org/builds/log/de.ph1b.audiobook/<versionCode>. - Published: Voice on F-Droid lists each version with the day it was added.
Tester Builds
To send a build to testers, trigger the Firebase App Distribution Workflow on the branch to test. Testers install the build through the Firebase App Tester app.
The workflow builds a playRelease APK with the version code of the upcoming release and a version name suffixed
with the commit, then uploads it with the Firebase CLI. It notifies the beta-testers group by default; clear the
groups input to only upload.
The APK is signed with the GitHub key, which is the app signing key Google Play re-signs with, so tester builds install
over the Play Store version.
App Distribution is not part of the Gradle build, so the free flavor and F-Droid builds don't depend on it.
It runs in the release environment and needs FIREBASE_APP_DISTRIBUTION_SERVICE_ACCOUNT: a service account with the
Firebase App Distribution Admin role in the Firebase project of the Play google-services.json.
Play Store Artwork
Play Store artwork is generated from the artwork/ Node project and written to fastlane/metadata/android/*/images.
Use:
The generator uses:
artwork/public/logo.svgfor the Play Store icon.artwork/public/raw/phone/*.pngfor raw app screenshots, captured at 1080×2424 on a Pixel 9 emulator with the system UI demo mode on.artwork/public/raw/feature/1.pngfor the phone in the feature graphic.artwork/src/templates/andartwork/src/styles/for the rendered Play Store images.PHONE_TONESintemplate.tsgives each screenshot a background that matches the colors of its screen.fastlane/metadata/android/*/marketing.ymlfor localized feature graphic text and screenshot captions.
Only locales with marketing.yml get localized generated images. The script removes old generated screenshot folders before writing the current output, so deleted tablet screenshots do not remain in the repository.
To take the raw screenshots again, run ./scripts/take_store_screenshots.py. It needs ffmpeg and starts an agent
emulator of its own (see the emulator skill), so your devices and library stay untouched. It adds a generated library
with the books of the kiosk mode, and uses kiosk mode for the library, bookmarks, history, and feature graphic.
The artwork/package-lock.json file is intentionally not checked in. CI and local generation use npm install from artwork/package.json to keep this small tool out of regular lockfile update churn.
Versioning
Voice uses calendar versioning (CalVer).
- Version name format:
YY.M.RELEASE YY: release year, last 2 digits (2025 → 25)M: release month (1–12)RELEASE: counter for releases within the same month (starting at 1)- Tag format (used by CI):
YY.M.RELEASE-CODE CODEis the numeric Android versionCode derived from the version name using the formula below.
Examples:
- First release in September 2025 → version name
25.9.1 - Tag includes version code →
25.9.1-5309001
Version code
The Android versionCode is calculated from the version name with:
CODE = (YY + 28)(MM as 2 digits)(RELEASE as 3 digits)
Example: 25.9.1 → (25+28)=53, MM=09, RELEASE=001 → 5309001.
This code is embedded in the git tag by determine_release_tag.main.kts and used by CI and Fastlane.
Pages Deployment
To projects Website uses Github Pages and Mkdocs. To deploy a new website, use dispatch a workflow manually.