Repository navigation
Development
- 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.
Run the main checks before pushing architecture or feature changes:
./gradlew verifyCi
git diff --checkverifyCi is an aggregate defined in the root build.gradle.kts:
-
verifyCiUnitTestruns:domain:testCiUnitTestand:app:testCiUnitTest. -
verifyCiPreflightrunsverifyTranslations,:app:checkCiDuplicateClassesand:app:compileCiAndroidTestKotlin. -
verifyCiLintruns:app:lintCiand:domain:lintCi. It is its own aggregate because CI runs it only on pushes tomain, 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.
For translation-only changes, the fast local check is:
./gradlew verifyTranslationsThat 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 --checkUnit 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
ActionCallbacksubclass, 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.
The local app uses Hilt in the :app module:
-
@HiltAndroidApponOpenVitalsApp -
@AndroidEntryPointonMainActivity -
@HiltViewModelfor 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.
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-wikiTwo 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 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 --checkLint 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 UTCschedule, or a manual run of the release workflow with targetnightly, builds:app:assembleNightlyand:app:assembleDebug, then publishesOpenVitals-nightly.apkandOpenVitals-nightly-debug.apkto the fixed GitHubnightlyprerelease. The same run builds:app:bundleNightlyand uploads the signed AAB to the Google Play open testing track, whose Play Developer API track name isbeta. The AAB is also attached to thenightlyprerelease asOpenVitals-nightly.aab. - A pushed
vX.Y.ZorVX.Y.Ztag builds:app:assembleReleaseand:app:assembleDebug, then publishesOpenVitals-vX.Y.Z.apkandOpenVitals-vX.Y.Z-debug.apkto its own versioned GitHub prerelease. The tag build also runs:app:bundleRelease, attachesOpenVitals-vX.Y.Z.aaband uploads it to the open testing track, beside the prerelease. The same run then waits for theproductionenvironment'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, andOPENVITALS_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 fortech.mmarca.openvitals. If the account is allowed to stage edits but not send them for review, set the repository Actions variableOPENVITALS_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_EMAILandZULIP_BOT_API_KEYfor a generic bot onopenvitals.zulipchat.com(Personal settings > Bots > Add a new bot) that is subscribed to thereleaseschannel; only theannounce-zulipjob sees them. -
MASTODON_ACCESS_TOKEN, only if the Mastodon job comes back: an access token for the@openvitals@techhub.socialaccount (Preferences > Development > New application, scopeswrite:statusesandread:accounts, redirect URI left at the defaulturn: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 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.
For a versioned prerelease:
- Bump
baseVersionNameinapp/build.gradle.kts. Letscripts/release.shbumpbaseVersionCodewithscripts/version-code.sh next; do not derive the code fromvX.Y.Z. - When preparing a store release, add Play changelog files under
fastlane/metadata/android/<locale>/changelogs/<versionCode>.txt. - Add the user-facing release summary to
CHANGELOG.md. - Update README or docs when the user-facing navigation, permissions, screenshots, or bundled assets change.
- Run:
./gradlew verifyCi
git diff --checkCI 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- Commit the release prep, tag the commit as an annotated
v<versionName>tag such asv0.7.0using the matchingCHANGELOG.mdsection 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 theproductionenvironment's approval. - 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
releaseschannel. If the run is gone, run the release workflow by hand (Actions > release > Run workflow) from the version tag with targetproduction: 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.
This wiki mirrors the Markdown in Hale-MTU/OpenVitals on every push to main.
Features
- Features
- Achievements
- Activity And Training Plans
- Activity Metrics
- Apple Health Import
- Beverage Logging And Caffeine
- Bluetooth LE Sensors
- Body Energy
- Body Metrics
- CoMaps Navigation Context
- CSV Import
- Cycle Tracking
- Daily Readiness
- Elevation Correction
- Feature Map
- FIT Files Import
- Food Logging
- GPX/KML/KMZ/TCX Route Import
- Health Connect Metrics Dashboard
- Health Report Export
- Heart And Vitals
- Heart Rate Recovery
- Home Screen Widgets
- Hydration
- Manual Entry Of Metrics
- Medical Records
- Metric Detail Customization
- Mindfulness
- Non Health Connect Metrics Dashboard
- Nutrition
- Offline Maps Support
- Onboarding And Permissions
- Preloaded Beverage Nutrition Reference
- Privacy, Support, And Diagnostics
- Recording Of Activity
- Reminders
- Settings And Preferences
- Sleep Score And Recovery
- Sleep Tracking
- Statistics
- Sync With Another Phone
How-to
App
- App Guide
- FAQ
- Getting Started
- Health Connect
- Install
- Local And Connected Editions
- Permissions
- Privacy
- Screenshots
- Support
Reference
- Design Reference
- Reference
- Design Guidelines for Offline & Sync
- OpenVitals UI Notes
- Reference: Data Capture
- Reference: Data Display
- Reference: Health Connect Matchmaking
- Reference: Health Connect Onboarding
- Reference: Health Connect Permissions
- Reference: Health Connect UX
- Reference: Promoting Health Connect
Engineering
- Code Analysis
- Engineering
- Flutter ↔ Kotlin test-parity matrix
- apple-health-import
- Architecture
- body-cycle-recovery
- Design system conformance
- Development
- Feature Playbook
- Flutter -> Kotlin test parity: domain/data/core/contract/state/navigation/di/bootstrap/l10n/widget_test
- Flutter → Kotlin test-parity matrix: features section
- Flutter → Kotlin test-parity matrix: ui / goldens / integration / fixtures
- Flutter-to-Kotlin test-parity matrix — dashboard + devicesync
- manual-entry
- Translations
Proposals
- Proposals
- Activity Start Flow Analysis
- Activity Start Flow Simplification Proposals
- Caffeine Detail And Future Sleep Insights
- Cycle Tracking: Design and Status
- Medical Records Through Health Connect
- Medical Records: What Is Left
Releases
- OpenVitals 2.16.0
- OpenVitals 2.15.2
- OpenVitals 2.15.1
- OpenVitals 2.15.0
- OpenVitals 2.14.5
- OpenVitals 2.14.4
- OpenVitals 2.14.3
- OpenVitals 2.14.2
- OpenVitals 2.14.1
- OpenVitals 2.14.0
- OpenVitals 2.13.0
- OpenVitals 2.12.0
- OpenVitals 2.11.2
- OpenVitals 2.11.1
- OpenVitals 2.11.0
- OpenVitals 2.10.1
- OpenVitals 2.10.0
- OpenVitals 2.9.1
- OpenVitals 2.9.0
- OpenVitals 2.8.0
- OpenVitals 2.7.3
- OpenVitals 2.7.2
- OpenVitals 2.7.1
- OpenVitals 2.7.0
- OpenVitals 2.6.6
- OpenVitals 2.6.5
- OpenVitals 2.6.4
- OpenVitals 2.6.3
- OpenVitals 2.6.2
- OpenVitals 2.6.1
- OpenVitals 2.6.0
- OpenVitals 2.5.0
- OpenVitals 2.4.1
- OpenVitals 2.4.0
- OpenVitals 2.3.2
- OpenVitals 2.3.1
- OpenVitals 2.3.0
- OpenVitals 2.2.8
- OpenVitals 2.2.7
- OpenVitals 2.2.6
- OpenVitals 2.2.5
- OpenVitals 2.2.4
- OpenVitals 2.2.3
- OpenVitals 2.2.2
- OpenVitals 2.2.1
- OpenVitals 2.2.0
- OpenVitals 2.1.0
- OpenVitals 2.0.4
- OpenVitals 2.0.3
- OpenVitals 2.0.2
- OpenVitals 2.0.1
- OpenVitals 1.9.0
- OpenVitals 1.8.0
- OpenVitals 1.7.7
- OpenVitals 1.7.6
- OpenVitals 1.7.5
- OpenVitals 1.7.4
- OpenVitals 1.7.3
- OpenVitals 1.7.2
- OpenVitals 1.7.1
- OpenVitals 1.7.0
- OpenVitals 1.6.3
- OpenVitals 1.6.2
- OpenVitals 1.6.1
- OpenVitals 1.6.0
- OpenVitals 1.5.1
- OpenVitals 1.5.0
Project