Skip to content

Development

github-actions[bot] edited this page Nov 9, 2026 · 1 revision

Build Requirements

  • Android SDK Platform 37.0 with SDK Build-Tools 37.0.0
  • JDK 17
  • Gradle wrapper files, including gradle/wrapper/gradle-wrapper.jar

The wrapper jar is intentionally tracked. .gitignore allows this file even though other jars are ignored.

Local Verification

Run the main checks before pushing architecture or feature changes:

./gradlew verifyCi
git diff --check

verifyCi is an aggregate defined in the root build.gradle.kts:

  • verifyCiUnitTest runs :domain:testCiUnitTest and :app:testCiUnitTest.
  • verifyCiPreflight runs verifyTranslations, :app:checkCiDuplicateClasses and :app:compileCiAndroidTestKotlin.
  • verifyCiLint runs :app:lintCi and :domain:lintCi. It is its own aggregate because CI runs it only on pushes to main, on a job of its own.

Nothing assembles an APK: nothing used it, and dexing it cost a minute and a half. The release pipeline builds the real one after the gate.

Unit tests run against the ci build type. There is no debug unit-test variant, so the task is :app:testCiUnitTest; :app:testDebugUnitTest does not exist. To run a single test class:

./gradlew :app:testCiUnitTest --tests 'tech.mmarca.openvitals.SomeTest'

verifyAndroidTest runs :app:connectedCiAndroidTest, but only locally and only when ANDROID_SERIAL is set; it is disabled in CI.

Translation Gate

For translation-only changes, the fast local check is:

./gradlew verifyTranslations

That task shells out to scripts/verify-translations.py, which enforces greater-than-70% coverage per locale, placeholder safety, plural shape, and translatable="false" handling. verifyCi includes it, so Weblate pull requests must keep locale files more than 70% translated and placeholders intact.

Current state: verifyTranslations passes and every locale file is at 100%. A locale under the threshold only prints a note — the coverage floor decides whether a language is offered in the picker, not whether its file may exist, so a translator's first commit does not break CI. Galician looked like this before it was completed:

note: app/src/main/res/values-gl/strings.xml is at 3.4% coverage and is not offered in the language picker until it passes 70%.

What still fails the build for every locale file, offered or not, is placeholder and plural-shape safety. A locale may carry more CLDR plural categories than the English base (Czech and Spanish need few/many); those extra branches are checked against the base other branch so they cannot drop an argument.

A new key goes to every values-*/strings.xml together with the base file, in the plural shape that locale needs; see translations.md. Weblate then refines the wording.

For Apple Health importer work, there is also a desktop JVM smoke test that can exercise the Kotlin importer against a real local export without building or installing the app:

./gradlew app:testCiUnitTest \
  --tests tech.mmarca.openvitals.features.imports.applehealth.AppleHealthImportSmokeTest \
  -PappleHealthExport=/path/to/export.zip \
  --console=plain

-PappleHealthExport can point to an Apple Health export.zip, export.xml, or an unzipped export directory. The test parses XML, parses GPX route files when present, runs supported Health Connect conversion logic, and prints import-shape counts. It is skipped automatically when no export path is provided.

On Windows, use gradlew.bat:

.\gradlew.bat verifyCi
git diff --check

R8 Keep Check

Unit tests run on the unminified ci variant, and no pipeline installs an APK, so nothing runs the R8 build before it ships. Every minify<Variant>WithR8 task is therefore followed by verify<Variant>R8Keeps, which runs scripts/verify-r8-keeps.py against that build's mapping.txt. It fails the build when R8 removed or renamed something that is only reached by name at run time:

  • a manifest component of ours, or its no-arg constructor
  • a Glance ActionCallback subclass, or its no-arg constructor (R8 once stripped one; widget taps were dead from 2.7.0 to 2.7.1)
  • a Health Connect record class named in SyncRecordCodec.kt, whose simple name is the phone-sync wire format

It needs no device, so it runs in the release pipeline as part of the build. To run it by hand: ./gradlew :app:minifyReleaseWithR8. When it fails, add a keep rule to app/proguard-rules.pro and say why. A new kind of by-name lookup needs a new check in the script.

Hilt And KSP

The local app uses Hilt in the :app module:

  • @HiltAndroidApp on OpenVitalsApp
  • @AndroidEntryPoint on MainActivity
  • @HiltViewModel for screen ViewModels
  • hiltViewModel() in navigation destinations
  • KSP for Hilt code generation

AGP 9 built-in Kotlin currently requires android.disallowKotlinSourceSets=false so KSP generated sources are accepted. Keep this in gradle.properties unless the Android Gradle Plugin/KSP behavior changes.

Docs and the Wiki

The wiki is a mirror of the repository's Markdown, rebuilt by .github/workflows/wiki.yml on every push to main that touches a .md file: README.md is Home, every other root Markdown file and every file under docs/ is one page, named after its first heading, with a sidebar by docs/ folder (each folder's README.md first, release notes newest first). Edit the files here, never the wiki: the next run overwrites it. The published site at docs.hale.ngo is separate and unchanged.

scripts/sync-wiki.py rewrites each relative link to the page it points at, or to the file on GitHub for anything that is not a page. Its --check mode is the verify gate's docs-links job and fails on a relative link to a file that does not exist. To see the pages locally:

python3 scripts/sync-wiki.py --repo Hale-MTU/OpenVitals --out /tmp/openvitals-wiki

Two Markdown files with the same first heading would become one page; the script stops and names both. The same script runs in OpenWatch, OpenLink and OpenBridge.

CI

CI is GitHub Actions on the stock ubuntu-26.04 runner, which ships JDK 17 and the Android SDK. It is pinned rather than ubuntu-latest, which moves from Ubuntu 24.04 to 26.04 between 2026-10-19 and 2026-11-19, so no job switches image mid-way. The image's default Java is 25; scripts/ci-gradle.sh takes its JDK 17 from JAVA_HOME_17_X64. scripts/ci-android-setup.sh installs the platform and build-tools the app compiles against when the runner image lacks them (a prebaked container image did this until 2026-10; pulling it cost as much as the install). The Gradle jobs cache Gradle with gradle/actions/setup-gradle (v5: v6 moves its caching into a proprietary component under Gradle's terms of use) and go through scripts/ci-android-gradle.sh, which sizes the JVMs for the runner's 16 GB. Two workflows. verify.yml is the gate, on every pull request and push to main: the app-test job always, lint on pushes to main only:

# tests job: every core gets a test fork, since nothing else runs here
./gradlew --no-daemon -Popenvitals.testForks=4 verifyCiUnitTest verifyCiPreflight
git diff --check
# lint job
./gradlew --no-daemon verifyCiLint
git diff --check

Lint alone is half of the gate's task time, so in a single job it stretched the tests, which shared the runner's four cores with it. A pull request's gate is the tests job alone; a lint finding is caught on main before a nightly ships.

The Gradle build cache is on (org.gradle.caching in gradle.properties), and setup-gradle keeps it between runs along with the dependency cache. A code change invalidates :app's compile and test tasks either way, so the win is on :domain while only the app changed, on reruns, and on the release build, which restores what the main run of the same commit produced.

release.yml is the release, on the schedule, a version tag and the manual run. A tag or the nightly is cut from a commit on main, whose push ran the verify workflow; the release's context job looks that run up, waits for it if it is still going, and stops the release if there is none or it failed, so the gate is never run twice and never skipped.

Its build job signs the APK, the debug APK and the AAB once and stores them as the run's release-build artifact; every job after it, production included, ships those files as they are. There are two GitHub release outputs:

  • The 00:00 UTC schedule, or a manual run of the release workflow with target nightly, builds :app:assembleNightly and :app:assembleDebug, then publishes OpenVitals-nightly.apk and OpenVitals-nightly-debug.apk to the fixed GitHub nightly prerelease. The same run builds :app:bundleNightly and uploads the signed AAB to the Google Play open testing track, whose Play Developer API track name is beta. The AAB is also attached to the nightly prerelease as OpenVitals-nightly.aab.
  • A pushed vX.Y.Z or VX.Y.Z tag builds :app:assembleRelease and :app:assembleDebug, then publishes OpenVitals-vX.Y.Z.apk and OpenVitals-vX.Y.Z-debug.apk to its own versioned GitHub prerelease. The tag build also runs :app:bundleRelease, attaches OpenVitals-vX.Y.Z.aab and uploads it to the open testing track, beside the prerelease. The same run then waits for the production environment's approval.

Each attached AAB has a .sha256 file beside it. The AAB is there for a Play upload by hand, when Play refuses the upload from the workflow, and it is what a manual production run ships: a run with target production, started from the tag, downloads the prerelease's AAB, checks it against the .sha256 and runs the production jobs on it. That is the way to ship a tag whose own run is gone (its approval lapsed, or it predates the workflow). The GitHub release job does not wait for the Play job, so the AAB is attached even then.

Nightly and release APKs use the release-style production application ID, minification, packaging, and signing model. Published Debug APKs use the separate tech.mmarca.openvitals.debug application ID and are signed with the stable release signing configuration so Debug-to-Debug updates keep the same certificate across ephemeral runners. Release APKs compress bundled native libraries and include only ARM 32/64-bit ABIs (armeabi-v7a and arm64-v8a) so direct-download APKs stay small.

The nightly release is intentionally mutable: each successful scheduled or manual nightly run moves the fixed nightly tag and replaces the existing APK and checksum assets instead of creating another release page. versionName and versionCode are intentionally detached: versionName carries the human release name (1.7.7, 1.7.7-nightly.335, where the suffix is the workflow run number), while versionCode is only a monotonic Android update counter. Both nightly and versioned releases use the same counter line. CI reads OpenVitals-Version-Code markers from existing GitHub release notes plus the append-only refs/version-code/* refs, uses max(markers, refs, baseVersionCode) + 1 for new release artifacts, stores the chosen code back into the release notes, and records it as a refs/version-code/<code> ref before the nightly tag moves (see scripts/move-nightly-tag.sh; a counter rewind on Codeberg in 2026-07 is why the refs exist, and they were copied over when the project moved to GitHub). The survey only sees GitHub: codes that exist solely on Google Play (such as the Flutter era's base*10 AAB codes, whose markers recorded the 9-digit base) are invisible to it, so baseVersionCode must be kept at or above every code any channel has ever served; the fastlane publish lanes additionally compare the incoming code against the live Play track before uploading. Production runs reuse the marker from the already published vX.Y.Z release so the Play AAB matches the GitHub APK's install order. The nightly run also prunes old versioned GitHub releases so only the newest nine remain, while preserving the fixed nightly release and all Git tags. Once approved, the run promotes the version from the open testing track to Google Play production with the store metadata (it uploads the AAB instead when open testing never had that version, as for a tag cut while Play was paused) and promotes the matching GitHub prerelease to stable, the two in parallel, then posts the full release notes to the Zulip releases channel under a vX.Y.Z topic (scripts/announce-zulip.sh). A Mastodon announcement from https://techhub.social/@openvitals (scripts/announce-mastodon.sh, the release notes' narrative paragraph plus links to the GitHub release and the Play listing) is disabled for now: the release workflow has no job for it. Both announcement scripts check for an existing post for the tag first, so re-running production never announces twice.

The scheduled run only skips the build when there is nothing new to publish: either the nightly tag already points at HEAD, or HEAD has no commits after the latest versioned vX.Y.Z release. Manual runs always build so an operator can force a refresh.

The workflow's own GITHUB_TOKEN creates and edits releases, pushes the nightly tag and the refs/version-code/* refs; a tag ruleset that blocks nightly or refs/version-code/* breaks the nightly. Configure these repository Actions secrets:

  • OPENVITALS_RELEASE_KEYSTORE_BASE64, OPENVITALS_RELEASE_STORE_PASSWORD, OPENVITALS_RELEASE_KEY_ALIAS, and OPENVITALS_RELEASE_KEY_PASSWORD, so CI can produce updateable Debug, nightly, and release APKs.
  • GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_BASE64, the base64-encoded JSON key for a Google Play service account that can release to open testing and production for tech.mmarca.openvitals. If the account is allowed to stage edits but not send them for review, set the repository Actions variable OPENVITALS_PLAY_CHANGES_NOT_SENT_FOR_REVIEW=true; the open testing or production release will then need to be sent for review manually in Play Console.
  • ZULIP_BOT_EMAIL and ZULIP_BOT_API_KEY for a generic bot on openvitals.zulipchat.com (Personal settings > Bots > Add a new bot) that is subscribed to the releases channel; only the announce-zulip job sees them.
  • MASTODON_ACCESS_TOKEN, only if the Mastodon job comes back: an access token for the @openvitals@techhub.social account (Preferences > Development > New application, scopes write:statuses and read:accounts, redirect URI left at the default urn:ietf:wg:oauth:2.0:oob - no OAuth flow is used; copy the "Your access token" shown on the application page).

The Play production upload runs in the production environment. Add required reviewers to that environment so a production run waits for approval.

While a release is in Play review, a new upload to any track restarts the review. Set the repository Actions variable OPENVITALS_PLAY_PAUSED=true to hold both Play uploads: the nightly skips its open testing job, and a production run still waits for approval, promotes the GitHub release to stable and posts to Zulip, but uploads nothing. Tags, GitHub releases and F-Droid, which builds from the tag, are unaffected. Delete the variable to resume. A version released while paused reaches Play only by hand, from the AAB on its GitHub release, or by a production run from its tag after the variable is gone; the next release's upload also supersedes it.

The OpenLink submodule

The app builds the OpenLink client from the git submodule at openlink/ (includeBuild("openlink") in settings.gradle.kts). Clone with git clone --recurse-submodules, or run git submodule update --init in an existing checkout. Every workflow checkout passes submodules: true. F-Droid builds from the tag with its own recipe in fdroiddata: that recipe needs submodules: true on the build entry from the first release that carries OpenLink, or the build fails at configuration.

Release Checklist

For a versioned prerelease:

  1. Bump baseVersionName in app/build.gradle.kts. Let scripts/release.sh bump baseVersionCode with scripts/version-code.sh next; do not derive the code from vX.Y.Z.
  2. When preparing a store release, add Play changelog files under fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt.
  3. Add the user-facing release summary to CHANGELOG.md.
  4. Update README or docs when the user-facing navigation, permissions, screenshots, or bundled assets change.
  5. Run:
./gradlew verifyCi
git diff --check

CI has no device, so it never runs the instrumented tests. A release does not need them. To run them anyway, connect a phone or emulator:

ANDROID_SERIAL=<serial> ./gradlew verifyAndroidTest
  1. Commit the release prep, tag the commit as an annotated v<versionName> tag such as v0.7.0 using the matching CHANGELOG.md section as the tag message, and push both the branch and tag. The tag's release run builds the signed artifacts once, publishes the versioned GitHub prerelease, and then waits for the production environment's approval.
  2. After validation, approve that run (Actions > the tag's run > Review deployments). It promotes the version from open testing to Google Play production and the GitHub prerelease to stable, then posts the release notes to the Zulip releases channel. If the run is gone, run the release workflow by hand (Actions > release > Run workflow) from the version tag with target production: it ships the prerelease's AAB the same way, without a build.

For an immediate nightly release, run the release workflow by hand from main with target nightly. It runs the same release checks, moves the nightly tag to the commit, publishes the APK to the mutable GitHub nightly prerelease, and uploads the signed AAB to Google Play open testing. The schedule does the same at midnight UTC.

Use the exact versionName for release notes, changelog references, and tags. For a final release, that means file name, versionName, and tag such as 1.0.0 / v1.0.0. Keep the Play versionCode unique and increasing, and add matching Fastlane changelog files for that exact code when preparing a store release.


Generated from docs/engineering/development.md. Edit it there; changes made here are overwritten.

Home

Features

How-to

App

Reference

Engineering

Proposals

Releases

Project

Clone this wiki locally