Repository navigation
Architecture
This document describes the architecture of OpenVitals as it exists today, plus the direction new work should follow.
The repo now has one Android app module for the local app. The goal is to keep boundaries clear enough that new metrics can be added without copying screen scaffolding, period math, or Health Connect plumbing everywhere, while keeping code app-local until there is a concrete need for a new module.
- App namespace:
tech.mmarca.openvitals - Project shape: two Gradle modules. The phone app is
:app, underapp/;:domainholds the app-local pure code the app depends on (see Module:domain). The rest of this document is about:appunless it says otherwise; package names are the same across:appand:domain. Watch and scale support is a separate app, OpenWatch, which writes to Health Connect; the two share no code - Dependency wiring: Hilt in
:app, rooted atOpenVitalsApp; modules aredi/AppModule.kt,di/RepositoryModule.kt,di/PreferencesModule.kt, anddi/RemindersModule.kt - UI stack: Jetpack Compose + Material 3 app shell + Navigation Compose +
ViewModel+ coroutines/StateFlow - Health data backend: Health Connect AndroidX client, wrapped by
HealthConnectManager - App-local domain code: pure models, insight calculations, and preference enums under
domain, in the:domainmodule - Shared period shell: in place and used by all metric detail/list screens
- Feature repositories: in place for activity, sleep, heart, body, body energy, caffeine, hydration, nutrition, mindfulness, cycle, and vitals
- Dashboard: still a dedicated day-based summary screen, not a period-detail screen
- Manual entry: separate from the dashboard and writes explicit user-entered records directly to Health Connect
- Room is at schema version 18. It holds derived summary caches plus the tables Health Connect cannot represent (
synced_record_origins, the food catalog, the cycle journal, cycle exclusions, pill intakes, and the index of kept medical documents); Health Connect remains the source of truth for everything it has a record type for - WorkManager is used for user-started Apple Health imports, offline map imports, and the home widget refresh
- Phone-to-phone Health Connect sync lives under
features/devicesyncand runs over Bluetooth Classic RFCOMM - A one-time Flutter-to-Kotlin data migrator lives under
data/migrationand runs fromOpenVitalsApp.onCreate()
Most importantly, body and entry/session browsing now live in metric-owned detail screens. The former global Browse destination is no longer part of the app architecture.
| Package | Owns |
|---|---|
comaps/ |
the CoMaps navigation provider: the ContentResolver source and the guidance feed |
core/ |
app-wide primitives: presentation, performance, diagnostics in :app; period, stats, geo, fit, export (share staging), privacy (masking text that leaves the app) in :domain
|
data/ |
local (Room), repository (feature-facing repositories + contract interfaces), sync (history/backfill services), migration (one-time Flutter import) |
di/ |
AppModule, RepositoryModule, PreferencesModule, RemindersModule
|
domain/ |
pure code: model, insights, preferences, cycle, dashboard (the aggregator), report (report roll-ups), medical (FHIR parsing, id repair, source grouping, the patient check, record summaries, the export bundle) in :domain; query and usecase, which reach into the repositories, in :app
|
features/ |
one package per user-facing feature area |
healthconnect/ |
the Health Connect integration boundary: manager, per-area readers, permission/UX services |
navigation/ |
routes and graph registration |
openwatch/ |
the OpenLink client to OpenWatch (the openlink/ submodule, a composite build): the body profile both ways, the dashboard watch tile and its sync, a recording session that keeps the watch streaming, and its live heart rate into sensors/ble
|
sensors/ble/ |
live BLE sensor streaming during activity recording |
ui/ |
shared components, charts, theme |
New product work should live under features/<feature>/.
Each feature owns:
- screen composables
- screen
UiState - screen
ViewModel - feature-specific charts, cards, rows, and formatting
Shared code should only move out of a feature when it is clearly reused by multiple screens.
The app now has a real shared shell for period-based screens:
- pull to refresh
- range selector
- period navigator
- date picker
- shared loading/error framing
That shell belongs in shared UI.
The actual metric presentation stays feature-local:
- steps charts
- sleep session timeline and stage bars
- heart trend/timeline cards
- workout rows
- weight/body composition cards
We do not want a universal chart abstraction that hides metric meaning.
The canonical interaction model for metric screens is:
Day / Week / Month / Year- selected anchor date
- previous/next navigation
- direct calendar selection
- forward navigation capped at the current period
- last selected range remembered independently per detail/list screen
This pattern is implemented today by app-local period primitives in core/period and shell components in ui/components.
Screens stay thin. ViewModels are responsible for:
- selected range/date state
- triggering loads and refreshes
- combining repository calls
- exposing UI-ready state
Screens should mostly collect state, wire callbacks, and render sections.
Health Connect specifics stay below the feature layer.
Repository methods should answer feature questions such as:
- load workouts for a period
- load sleep sessions for a period
- load heart summaries for a period
- load body entries for a period
They should not keep growing into one large grab-bag repository with screen-specific overloads.
The current app does not need:
- a reducer/effect architecture
- a multi-module split
- a raw Health Connect mirror
Two Room caches exist, and each has a reason: the vitals daily cache, because seven vitals types have no Health Connect aggregate, and heart_rate_days, because the day view's average needs every sample.
The first is invalidated by a permission-and-zone fingerprint, the second by a per-day signature of the hourly aggregates.
Everything else reads Health Connect on every load, through one reader function per metric and shape. A new cache needs a fingerprint and a reason as strong as these.
Package boundaries come first; a module is a build-time decision, not a tidying one. There is one extraction, :domain, the app-local pure code, because lint and the unit tests of one 215k-line module ran on one thread for ten minutes: a module with no dependency on the rest of the app gets its own lint and test tasks, which run beside the app's and stay cached while only the app changes. A module is extracted when two apps need the same code, or when a layer that nothing below it depends on is large enough to shorten the gate. Prefer package boundaries first:
- app-local domain models and calculations in
domain(the:domainmodule) - period primitives in
core/period(also:domain) - app-only resources, navigation, Hilt wiring, and local policy in
:app
These are logical layers inside the local app module.
Responsibilities:
- app startup
- Hilt application/component setup
- theme setup
- route registration
- adaptive top app bar, navigation suite, and global action shell
Current files:
OpenVitalsApp.ktMainActivity.ktdi/AppModule.ktnavigation/AppNavigation.ktnavigation/Screen.ktui/components/OpenVitalsAdaptiveScaffold.kt
Notes:
-
OpenVitalsAppowns the Hilt application component and locale bootstrap. -
MainActivityowns the onboarding-complete preference and chooses the start destination. -
AppNavigationowns route registration and top-level destination selection; route composables obtain@HiltViewModelinstances throughhiltViewModel(). -
OpenVitalsAdaptiveScaffoldowns the Material 3 top app bar,NavigationSuiteScaffold, and contextual Add action.
Responsibilities:
- Health Connect availability checks
- permission queries
- record reads and aggregate reads
- explicit manual-entry writes to Health Connect
- mapping Health Connect responses into app models
- feature-facing repository APIs
Current files:
healthconnect/HealthConnectManager.ktdata/repository/HealthRepository.ktdata/repository/ActivityRepository.ktdata/repository/SleepRepository.ktdata/repository/HeartRepository.ktdata/repository/BodyRepository.ktdata/repository/HydrationRepository.ktdata/repository/NutritionRepository.ktdata/repository/MindfulnessRepository.ktdata/repository/CycleRepository.ktdata/repository/VitalsRepository.ktdata/repository/PreferencesRepository.kt- feature-oriented model files under
domain/model
Current boundary shape:
-
HealthConnectManageris the low-level integration wrapper. It talks to the AndroidX client, performs reads, writes explicit manual entries, and maps results into app models. -
HealthRepositoryis now intentionally narrow: Health Connect availability and permission state. Dashboard loading isDashboardDataLoader, behindLoadDashboardDayUseCase. - Feature repositories are thin, permission-aware facades over
HealthConnectManager. - Manual entry ViewModels use the same feature repositories for writes, so write permission and write behavior stay below the UI route.
-
healthconnectdepends on app-local domain models; repositories depend onhealthconnectanddomain. The readers, writers and mappers import nothing fromdata.repository. Five permission and gate classes do: they readPreferencesRepository, and one reads theHealthRepositorycontract.HealthConnectLayeringTestlists them and fails on a sixth.
This is a meaningful improvement over the earlier centralized repository approach. New feature reads should follow the feature-repository pattern, not expand HealthRepository.
Some repositories are now split into a data/repository/contract/ interface and an implementation, bound in di/RepositoryModule.kt. Use that split when a repository is a seam another subsystem writes through, not by default.
OpenVitalsDatabase is at VERSION = 18, with migrations declared in its companion object and listed once, in ALL_MIGRATIONS, which the database builder takes. Room exports the schema of each version to app/schemas; the files are committed.
To change the schema: raise VERSION, write the migration, add it to ALL_MIGRATIONS, build once, and commit the new schema file. OpenVitalsDatabaseSchemaTest fails until all of that is done: it replays every migration's statements and compares the tables they leave with the exported schema. It can do that on the JVM because the migrations only create and drop tables. A migration that alters a table needs a real database, so add room-testing and a MigrationTestHelper test with it; the exported schemas are what that helper reads.
| Table | Package | Purpose |
|---|---|---|
beverages |
data/local/beverage |
user and preloaded beverage catalog |
vitals_daily_aggregates, vitals_sync_cursors
|
data/local/vitalscache |
derived daily summary cache and change-token cursors |
body_energy_days, body_energy_buckets
|
data/local/bodyenergy |
the Body Energy chain, moved off SharedPreferences in migration 4 → 5 |
synced_record_origins |
data/local/syncorigin |
the source app of each record received from another phone, added in migration 6 → 7 |
heart_rate_days |
data/local/heartratecache |
each day's heart-rate average from raw samples, read again when the day's hourly aggregates change, added in migration 10 → 11 |
foods, food_nutrients
|
data/local/food |
the user's food catalog and each food's nutrients, added in migration 11 → 12; a logged portion is a Health Connect nutrition record, not a row |
cycle_journal_entries, cycle_exclusions
|
data/local/cycle |
the cycle day log Health Connect has no record type for (pain, mood, energy, desire, symptoms, notes, pregnancy test, temperature disturbances, cervical sensation, cervix opening, firmness and position) and the cycles kept out of the estimate, added in migration 12 → 13; the cervix and desire columns in 16 → 17 |
pill_intakes |
data/local/cycle |
the days the contraceptive pill was marked as taken; the scheme itself is a cycle preference, added in migration 13 → 14 |
medical_documents, medical_document_records
|
data/local/medical |
the files kept from medical imports, stored under files/medical_documents, and the Health Connect records each one gave; a file is data Health Connect cannot hold, and the records stay in Health Connect. Added in migration 14 → 15 |
Three tables the device layer owned (garmin_wellness_samples, added in 5 → 6; garmin_sleep_minutes, 9 → 10; scale_weigh_ins, 15 → 16) left with that layer: MIGRATION_17_18 drops all three. Their data lives in OpenWatch now.
data/migration holds a one-shot importer that brings Flutter-era user data forward. The Kotlin build installs over the Flutter build with the same applicationId and certificate, so the app's private directory survives the swap, and the Kotlin-era files still sitting in it are about a month stale. The migrator therefore overwrites them with the Flutter values on its guarded first run.
It is deliberately split into two phases around super.onCreate() in OpenVitalsApp. This ordering is load-bearing, not stylistic:
-
Preferences, before
super.onCreate().@HiltAndroidAppmember-injects theApplicationduringsuper.onCreate(), andPreferencesRepositoryeagerly snapshots itsSharedPreferencesintoStateFlows at construction. Any preference write that lands after that point is invisible to the running app. SoFlutterDataMigratoris constructed by hand rather than injected, and every preference write goes through rawSharedPreferenceswithcommit()rather than through a repository that may not exist yet. -
Database import, after
super.onCreate(). The database phase needs the Hilt-provided Room singleton, which cannot exist before the Hilt component does. Room singletons are created lazily on first request, and noActivitycan exist yet, so resolvingOpenVitalsDatabasethroughFlutterMigrationEntryPointimmediately aftersuper.onCreate()is both possible and safe.
migrateIfNeeded() returns whether a migration is in flight; only then does onCreate call importDatabaseAndFinish(...). The migrator never throws, sets its one-shot flag regardless of per-step failures so a failing migration cannot retry on every launch, never deletes a Flutter file, and no-ops on a fresh install after a single file stat.
If you add a new preference or store that must survive the migration, add it to phase 1. Do not move phase 1 work behind Hilt.
Responsibilities:
- reusable shell components
- period selection primitives
- date navigation UI
- loading/error primitives
- dashboard/detail card building blocks
Current files:
ui/components/MetricDetailScaffold.ktui/components/PeriodNavigator.ktui/components/DateNavigation.ktui/components/MetricCard.ktui/components/LoadingState.ktui/components/PullToRefreshBox.ktui/components/PermissionCallout.kt
Important current detail:
-
TimeRange,DatePeriod,PeriodLoadQuery,PeriodWindows,PeriodSelectionDriver, and period formatting helpers live incore/period -
PeriodRangePreferenceKeylives incore/period;PreferencesRepositorypersists the last selectedTimeRangeper detail/list screen -
PeriodNavigatorremains a UI component inui/components
Responsibilities:
- feature contracts (
UiState, actions, derived display fields) - screen-specific orchestration
- feature-specific cards/charts/lists
- feature-specific display language
Current feature packages:
features/achievementsfeatures/onboardingfeatures/dashboardfeatures/activityfeatures/sleepfeatures/heartfeatures/vitalsfeatures/bodyfeatures/bodyenergyfeatures/caffeinefeatures/cyclefeatures/devicesyncfeatures/homewidgetsfeatures/hydrationfeatures/imports/applehealthfeatures/imports/csvfeatures/manualentryfeatures/mindfulnessfeatures/nutritionfeatures/readinessfeatures/recoveryfeatures/settings-
features/workoutplans— plan list/builder;WorkoutPlanStepMapping.ktis the one seam between Health Connect plans and the activity form's step rows
Two of these are not metric features and follow their own shape: features/devicesync is a phone-to-phone sync wizard, and features/imports/* are import workflows.
One practical note: features/activity currently contains two screen families:
- concrete metric entry screens such as
StepsScreen,DistanceScreen,CaloriesOutScreen,ActiveCaloriesScreen,FloorsScreen, andElevationScreen -
ActivitiesScreenfor workout sessions
That is a reasonable local compromise today because these screens share ActivityRepository, but route-facing composables should stay metric-specific. Shared renderers inside a feature package are acceptable when they only remove local duplication and do not make the user-facing detail screen show several metrics at once.
Two implemented features intentionally do not follow the canonical period-detail interaction:
-
features/caffeineis a caffeine-specific analytics and setup experience with custom ranges, active-caffeine modeling, timing guidance, and beverage/nutrition context. -
features/bodyenergyis a selected-day derived wellness detail, not aDay / Week / Month / Yearmetric screen.
Cross-metric insight calculations should live in domain/insights, even when the card is rendered by one feature. The feature ViewModel or use case can load the secondary signal, and the presentation mapper can attach the resulting insight to the feature display state.
This keeps metric UI declarative: composables render precomputed insight models and do not own thresholds, correlation rules, or score adjustments. Missing secondary data should remain neutral. For example, planned caffeine-aware sleep insights should attach caffeine signals to sleep presentation state only after the domain signal and mapper are implemented; missing caffeine records must not reduce sleep scores. Today, caffeine timing guidance lives in the standalone caffeine feature.
core/fit/FitDecoder.kt, in :domain, is the one FIT decoder in the app. It was extracted from the route importer's private decoder once a second consumer appeared.
It is a generic container walk and nothing more: it reads the FIT header, definition and data messages, compressed timestamps and developer fields, and returns FitMessage values keyed by global message number. It has no message allowlist and no semantics.
Interpretation lives with the consumer: features/manualentry/activity/routeimport/FitRouteParser.kt interprets activity, course, and workout files, and the nightly HRV readings a FIT wellness file carries for features/imports/garmin/FitHrvImportService. A second FIT consumer should follow the same split: reuse FitDecoder, own its interpretation.
The app-local code that nothing in the rest of the phone app sits below, in domain/: the packages domain/model, domain/insights, domain/preferences, domain/cycle, domain/dashboard, domain/report and domain/medical, and the primitives core/period, core/fit, core/geo, core/stats, core/export and core/privacy. Package names are unchanged, so an import does not say which module a class is in. :app depends on it.
It is an Android library, not plain Kotlin: a few files use Health Connect record constants (ExerciseTypeTraits, PlannedExerciseSteps, the activity splits) and the platform's locale data (SystemUnitSystemProvider, AppLanguage), and Android lint then still checks it. It has the app's build types (ci, nightly), so the app's ci variant takes the module's ci variant and the module's tests and lint run on that one compile. Nothing in it is injected: it holds no Hilt module, and its classes are constructed by the app's repositories and the app's di/ modules.
Its test fixtures (testFixtures) hold the FHIR, C-CDA and SMART Health Card fixtures that both its own tests and the app's import tests use, and the privacy sentinels (PrivacySentinels) that tests of crash drafts, logs and reports seed and look for, with the FHIR sample files under domain/src/testFixtures/resources/fhir/. Its tests and lint are part of the phone gate (verifyCiUnitTest depends on :domain:testCiUnitTest, verifyCiLint on :domain:lintCi), its lockfile is domain/gradle.lockfile, and the ratchet tests and the runBlocking allow-list cover its files under the same package-relative paths as the app's.
The module exists for the build: the app's lint and tests no longer cover these lines, and a change elsewhere leaves this module's compile, lint and test results in the build cache. What does not belong here: anything with an inbound dependency from data/, healthconnect/ or features/ (domain/query and domain/usecase stay in :app for that reason), resources, and Hilt wiring. ArchitectureDocTest fails when a Gradle module is missing from this document.
features/devicesync copies Health Connect records between two phones over Bluetooth Classic RFCOMM. It is a feature because it talks to another instance of this app rather than to a peripheral.
Three sub-packages, in dependency order:
-
bluetooth/— the only Android Bluetooth code.BluetoothSyncManager(@Singleton) owns discoverability, discovery, the socket, and the transport;RfcommServer/RfcommClientopen the socket on a private app UUID;RfcommByteChannelpumps bytes;BluetoothDiscoveryReceiverbridges the discovery broadcasts. -
protocol/— pure Kotlin over aSyncByteTransportseam, so the whole protocol is testable over an in-memory pipe with no Bluetooth.SyncFrameis a length-prefixed frame whoseSyncFrameTypeordinal is the wire byte, so the enum is append-only.SyncMessagescarriesSYNC_PROTOCOL_VERSIONand compact JSON payloads with a gzipped record batch.SyncPairingholds the cryptography: an ECDH P-256 exchange with a committed host key, HKDF, six digits the two users compare, andSyncFrameCipher(AES-256-GCM, one key per direction, a frame counter as nonce).SyncSessionis the state machine: handshake, key exchange, code check, negotiate types, then a symmetric bidirectional exchange where both phones run the same code and differ only bySyncRole. No record frame is accepted until both users confirmed the code, and every frame after the key exchange except an abort is sealed. Never go back to a typed code proved with a MAC: six digits can be guessed offline from one recorded handshake. -
store/— the data side, kept out of the protocol.SyncRecordCodecencodes and decodes records and derives the content fingerprint used for dedup;HealthConnectSyncStoreimplements the protocol'sSyncRecordStorefor Health Connect records;CycleJournalSyncStorecarries the cycle journal from Room;MedicalRecordsSyncStorecarries FHIR medical records as export entries and writes them through the medical import pipeline;CompositeSyncStoreroutes each record type to one of them;DeviceSyncReportStorewrites the last report as plain text underfilesDir/device_sync/.
There is no Room table and no DataStore here. What sync persists is Health Connect itself, plus that one report file.
DeviceSyncForegroundService is an inert keep-alive: the RFCOMM pumps run in the wizard's ViewModel, and the service exists only to hold the foreground slot for the duration of a transfer. start() is best-effort and stop() only ever stops its own class, so a sync that never got the slot cannot tear down someone else's service.
The dashboard is intentionally different from the period-based detail screens.
It is:
- a daily snapshot
- navigated by day only
- powered by one aggregated
DashboardDataobject - the main entry point into feature screens
Current files:
Shared pieces it uses:
PullToRefreshBoxDayNavigatorHealthDatePickerDialogMetricCardPermissionCallout
The dashboard should stay summary-first. It should not become a second copy of detail-screen logic.
Dashboard metric cards route to metric-specific detail destinations. Metrics that share a repository can still reuse the same feature package and ViewModel, but navigation should call concrete metric screen entry points such as ProteinScreen or RestingHeartRateScreen, not a public screen with a metric parameter. The rendered detail view should focus on the selected metric instead of showing every related metric in one grouped screen. There is no global records browser or fixed dashboard browse action; entry and session lists belong behind the relevant metric card/detail screen.
Manual entry is a separate screen family from the dashboard. It is the only app area that should initiate user-entered Health Connect writes. The Add entry picker is reached through contextual create actions on the dashboard and supported metric screens, not as a primary browsing destination.
The other write paths are all imports or transfers rather than typed entry: features/imports/applehealth, features/imports/csv, features/imports/garmin (the HRV of a FIT wellness file) and features/devicesync. They share one write door, AppleHealthImportRepository.insertImportedRecords, so deterministic clientRecordId upserts behave identically across them. The workouts of the app's own CSV export are the one import that takes another door: features/imports/csv/CsvWorkoutWriter.kt writes them through ActivityRepository.writeActivityEntries, as the route and FIT bulk import do, so they land as the app's own activities with the session's totals and series beside them. Medical records (FHIR) are the exception. They have no clientRecordId, so they are written through healthconnect/MedicalRecordsWriter, where a record's data source, type and id make a rewrite an update.
Current files:
features/manualentry/ManualEntryScreen.ktfeatures/manualentry/ManualEntryViewModel.ktfeatures/manualentry/activityfeatures/manualentry/activity/recordingfeatures/manualentry/activity/routeimportfeatures/manualentry/hydrationfeatures/manualentry/foodfeatures/manualentry/nutritionfeatures/manualentry/bodyfeatures/manualentry/vitalsfeatures/manualentry/mindfulness
The current manual entry widgets cover hydration, food from a catalog the user builds, carbohydrate totals, activity sessions with manual entry, existing plans, or GPS recording, activity file review launched from Settings Data Importers for GPX/KML/KMZ, TCX, and FIT files, mindfulness, body composition, height, blood pressure, SpO2, respiratory rate, body temperature, HRV, blood glucose, and VO2 max. Body composition is one form for what a body analysis scale shows: weight, body fat, lean mass, body water, bone mass, and BMR. The filled values are written in one insertRecords call with one time, so a weigh-in is saved whole or not at all; body water may be typed as a percentage, which becomes a mass of the weight on the same form, never of an older one. Each value stays its own record afterwards, so the Body screens edit and delete it alone, through the same form showing that one field. The daily BMR estimate is written by its own pass and is not offered for editing. Blood glucose is typed in mmol/L or mg/dL, starting from the unit setting, and carries Health Connect's relation to meal, meal, and sample; VO2 max carries how it was measured. Health Connect's constants for those fields are mirrored in GlucoseRecordValues and Vo2MaxRecordValues, and EntryWritePathsTest checks the mirrors against the library. Every vitals type maps to and from its record in healthconnect/VitalsRecordMapping.kt. A blood pressure reading can carry the pulse the monitor showed: it is written as a one-sample HeartRateRecord at the reading's time whose clientRecordId derives from the reading's with its tokens stripped (bpPulseClientRecordId), so an edit of the reading rewrites it, clearing the field deletes it, and deleting the reading deletes it too. The value is also encoded on the reading's own clientRecordId as a _bppulse-<bpm> token beside the _bpctx- meal context, which is what lets a list of readings and the edit form show it without a heart rate read. Saving a pulse needs heart rate write access on top of blood pressure; a reading without one never asks for it. Widget order is customizable in the same spirit as the dashboard, but the dashboard remains read-only.
Write permissions can be requested during one-tap onboarding or lazily from Add entry and the specific metric entry route. The dashboard remains read-only. Each write goes directly to Health Connect; OpenVitals keeps only local UI preferences such as widget order and mindfulness timer/background-sound settings.
The aligned detail/list screens are:
- steps/activity
- activities
- sleep
- heart
- body
- hydration
- nutrition
- mindfulness
- cycle
- vitals
They all use MetricDetailScaffold as the shared shell.
The scaffold currently owns:
- pull to refresh
- time range selector
- period navigator
- date picker
- shared error block
-
headerItemsslot -
content: LazyListScope.(DatePeriod) -> Unitslot
This is the main reusable architectural frame for metric work in the app today.
Onboarding and Settings are not metric screens, but they are important architectural surfaces because they centralize Health Connect availability and permission management.
Current files:
For availability and permission state these screens should keep using HealthRepository, not feature repositories.
Settings is one route per section, and each section builds only its own ViewModel: DisplaySettingsViewModel, ActivitiesSettingsViewModel (recording, offline maps, elevation tiles), NutritionSettingsViewModel, BodySettingsViewModel (shared by Body profile and Recovery, because the profile, the zones and the night window all feed the Body Energy chain), DataImportViewModel, and the bespoke Sync with another phone screen. SettingsViewModel keeps only the root, Health Connect, Vitals and Diagnostics: availability, permissions, and a few switches. A new section gets its own ViewModel and screen; do not grow SettingsViewModel.
Health Connect-backed screens (dashboard, metric detail, readiness, manual entry, imports) should wrap content with the shared shell:
-
HealthConnectFeaturemaps destinations to permission sets -
HealthConnectScreenUxCoordinatorloads sync/access/contextual-prompt state -
WithHealthConnectFeatureScreencomposes access gate, sync banner, contextual promotion, and permission launcher
Metric detail screens pass syncPaused from the shell state into MetricDetailScaffold and set showInlineSyncBanner = false to avoid duplicate banners.
These five rules hold app-wide. Breaking one is not a local decision.
The app treats the Android foreground slot as effectively single. Activity recording, the Apple Health import, and a phone-to-phone sync contend for it, and the app does not run them concurrently:
-
ActivityRecordingService—location|health|connectedDevice. -
DeviceSyncForegroundService—connectedDevice. - WorkManager's
SystemForegroundService—dataSync, used by the Apple Health and offline map import workers.
The contention is resolved by refusing, not by queueing: the sync wizard reports RECORDING_ACTIVE while a recording is live. A new long-running workflow must either reuse one of these or state which one it excludes. No service starts without a tap: the scheduled workers (the home widget refresh) are plain CoroutineWorkers, never expedited or foreground ones, because work the user did not start must not be able to take the single slot, and it has no business posting a notification about itself.
Live BLE sensor streaming during activity recording lives in sensors/ble; it runs while the recording holds the slot and takes none of its own.
The per-area readers in healthconnect — ActivityHealthReader, SleepHealthReader, HeartHealthReader, BodyHealthReader, VitalsHealthReader, HydrationHealthReader, NutritionHealthReader, MindfulnessHealthReader, CycleHealthReader, MedicalRecordsHealthReader — own the record types, the reads, and the mapping into app models. The medical reader and MedicalRecordsWriter reach Health Connect through MedicalRecordsClient, because the Jetpack medical classes build platform objects in their constructors and a JVM test cannot create them. HealthConnectMedicalRecordsClient is the only class that touches them. Repositories consume readers; features consume repositories. No feature should call the AndroidX client or hold a raw Record in screen state.
Two bounded exceptions exist today and should stay bounded:
-
Constant vocabularies. Display code may reference Health Connect's constant sets where the app has no reason to mirror them —
ExerciseSessionRecordexercise types,ExerciseSegment,MealType,SexualActivityRecordprotection values. That is naming, not data access. -
Write and import paths. Importers and sync legitimately build
Recordinstances. Each concentrates that in one place (features/imports/applehealth,features/imports/csv,features/imports/garmin,features/devicesync/store/SyncRecordCodec.kt) and writes throughAppleHealthImportRepository.insertImportedRecords, which is what makes deterministicclientRecordIdupserts consistent across all of them. Medical records write throughMedicalRecordsWriterinstead, and the CSV importer's workout preset buildsActivityWriteRequests forActivityRepository.writeActivityEntriesrather than records.
A writer reads strictly. The readers return a fallback (an empty list, a null) when a read fails, is rate limited, or sync is paused. That suits a screen, which wants an empty state. It is wrong for code that saves what it read: a cache rebuild, a derived-record reconcile. Such code wraps its reads in withStrictHealthConnectReads { ... } (HealthConnectReaderSupport.kt). Inside it every guarded read throws instead, in child coroutines too, so the pass aborts before it writes. VitalsHistorySyncService, StepDistanceBackfillService, BmrEstimateService and BodyEnergyChainSyncService do this. A new writer must too. So does code that puts what it read into a document the user keeps: ReportDataLoader reads strictly, so a rate-limited metric shows as failed in the report and not as "No data in this range". MedicalRecordsHealthReader has no fallback at all: every read throws, so a medical screen never shows "no records" for a failed read.
A refusal is one state for the whole process. Health Connect's quota is per app and per window, and the standalone Health Connect app (Android 13) is stricter than the platform module. HealthConnectRateLimitBackoff (HealthConnectRateLimit.kt) holds one deadline for every reader: it climbs 1, 2, 5, 15 minutes when hits keep coming inside half an hour, a hit from a read that was already out when the last one landed is the same burst and moves nothing, and the deadline is a StateFlow the dashboard banner counts down from. HealthConnectReaderSupport reads the backoff under its two-permit semaphore, so a read queued behind a refused one returns its fallback without a call; it also spaces call starts with HealthConnectCallPacer (150 ms on the standalone provider, 250 ms on any provider after the first refusal) and tallies every call by operation name in HealthConnectCallCounters, which the report email and the diagnostics export print. A screen read marks HealthConnectReadOutcome when it is skipped or refused, the way strict reads are marked; the dashboard keeps the tiles whose pass came back degraded, holds them in place rather than sorting them as empty, and DashboardRateLimitRecovery reloads them alone once the deadline passes. Below Android 14 the granted set and the feature status are calls into the Health Connect app too: HealthConnectPermissionService keeps a good answer for thirty seconds, drops it when a permission request returns or the app comes back to the foreground, and never caches a failure. HistorySyncScheduler defers the drains after a load that had reads refused, and BodyEnergyChainSyncService walks three days a pass and sits out a refusal. Nothing else may invent a second backoff.
A writer checks that it can see other apps' data. In the background, without the background-read grant, Health Connect answers a read with this app's own records only and raises nothing. HealthConnectManager.readsOtherAppsDataNow() is the test: a started Activity, or the grant. Code that saves or shows what it read while no Activity is up (a cache rebuild, a reconcile, a widget refresh) calls it first and skips the pass when it is false. A reminder that only needs this app's own entries does not need it.
Throwable.isPermissionFailure() is the single predicate for "this failed because a permission is missing". It walks the cause chain looking for SecurityException, because Health Connect throws that for an ungranted read or write and repositories throw the same type when they short-circuit a call whose permission they know is missing, often wrapped by a repository or worker.
ScreenErrorHandler.handle checks that predicate first and returns ScreenError.PermissionDenied before it ever considers throwable.message. That matters because the screens turn this case into a grant affordance: ScreenErrorContent and MetricDetailScaffold render PermissionDenied as HealthConnectPermissionDeniedCallout rather than red error text. Collapsing it into ScreenError.Message would silently downgrade a recoverable state into a dead end.
AppleHealthImportErrorFormatter.isPermissionDenied delegates to the same predicate so the import card and the screen error path cannot drift apart. Reuse it; do not pattern-match on exception messages.
A blocked main thread is an "app isn't responding" dialog. A broadcast receiver has about 10 seconds; a touch has 5.
-
Loads start and publish on Main.
LoadCoordinator.launch(viewModelScope)runs there, and_uiState.value = _uiState.value.copy(...)is only safe there. Move the heavy part withwithContext, not the whole load. -
Health Connect and Room calls are
suspendand run ondispatchers.io. Mappers that walk samples run ondispatchers.default. InjectDispatcherProviderwith the= DefaultDispatcherProviderdefault so tests can swap it. -
No
runBlockinginapp/src/main.NoRunBlockingRatchetTestholds the allow-list. -
A receiver never holds a broadcast for a Health Connect read.
UpdatingHomeWidgetReceiverlets Glance redraw from stored state and hands the read toHomeWidgetRefreshScheduler.refreshNow(). Do not callgoAsync()in a Glance receiver: Glance already took the pending result, so a second call returns null. -
A widget tap writes first.
runQuickBeverageTapdoes the write, then the tile update. The label revert runs onHomeWidgetScope, after the broadcast ends. Its time budget stops the wait, never the write. -
Composables
rememberany pass over samples. A day of 1 Hz heart rate is about 86,000 samples. Sort, min, max and average belong inremember(samples)or in the mapper.
When the dialog does appear, Android keeps the trace. AnrExitInfo reads it into the report email and the debug log export, with the system's reason masked. On a device: adb shell dumpsys activity exit-info tech.mmarca.openvitals.
Anything the user can copy, save, share or email holds structure only: counts, record types, units, reasons, row and column numbers, class names and stack frames. Never a record's value or time, a name, a file name or location, an address or an identifier. A report the user posts on a public issue must be safe to post.
-
Reports print structure from the start. The Apple Health, CSV and medical import reports and the sync report never hand a value to a cleaner: a refused record is its type and reason, a cell that could not be read is its shape (
PrivacyRedactor.shapeOf), a zip's files are their place and extension. A pattern cannot recognise a name, so nothing relies on one to remove it. -
Error text goes through one redactor.
core/privacy/PrivacyRedactor(in:domain) masks an exception message: addresses, identifiers, file locations, dates and every number but a position ("line 3", "row 7").maskThrowablerebuilds a stack trace from the throwable, so a message's later lines never appear. Usedescribe/maskThrowablewherever an exception's text reaches a report, the screen's copy button or a draft. -
A crash draft carries the masked log. The saved crash report holds the masked crash and the masked log of the crashed process, is attached for seven days at most, and is deleted once it has gone into an email, a copy or a file. The "Report an issue" draft adds the current process's masked log. The log, there and in the diagnostics log export (debug and nightly builds), keeps every line and tag and masks each through
maskLogcat; a release build has no log to give and says so. -
A log line never splices in a value. Log a count, a class name, or pass the throwable as the last argument; the export masks a throwable's message whole.
LogMessageInterpolationRatchetTestholds the log calls that splice in an exception message, and its count may only fall. -
A copy is marked sensitive. Every clipboard write of a report goes through
sensitiveTextClip(core/presentation/SensitiveClipboard.kt), so Android 13 and later keep it out of the copy preview. -
The diagnostic files stay on the phone.
res/xml/data_extraction_rules.xmlleavescrash_reports/,import_reports/anddevice_sync/out of a transfer to a new phone;LocalAppManifestPolicyTestchecks it.
Each report and the crash draft have a test that seeds PrivacySentinels (the :domain test fixtures) and asserts none comes out.
New metric detail work should follow this shape.
At minimum:
UiState- selected range
- selected date
- loading state
- feature payload
- error state
Keep derived fields in the state only when they genuinely simplify the UI.
Today the shared period model is:
-
TimeRange,DatePeriod,PeriodLoadQuery,PeriodWindows, andPeriodSelectionDriverincore/period
The feature should load data against the selected period query rather than inventing custom navigation rules.
The ViewModel should:
- update range/date
- clamp future navigation
- compute the active period
- call repositories
- expose UI-ready data
Most current ViewModels already follow this shape.
The screen should pass shared shell parameters and provide only feature content.
The content lambda should render:
-
Daymode content -
Week / Month / Yearcontent - optional list/breakdown sections
When registering a new period-based screen, add a PeriodRangePreferenceKey and inject PeriodPreferences into the screen ViewModel so the saved range is owned with the rest of the feature state. Persist only range changes; selected dates remain screen state.
If the feature needs a custom chart, row, or timeline, keep it in the feature package unless another feature genuinely needs the same thing.
Keep using HealthRepository for:
- availability
- permission contract access
- granted/missing permissions
Do not add new feature-detail data methods there unless the app is in a temporary migration step.
Follow the current pattern:
ActivityRepositorySleepRepositoryHeartRepositoryBodyRepositoryHydrationRepositoryNutritionRepositoryMindfulnessRepositoryCycleRepositoryCaffeineRepositoryBodyEnergyRepositoryVitalsRepository
Each repository should:
- guard required permissions
- call
HealthConnectManager - return app models ready for the ViewModel
Not every repository is a Health Connect facade. CycleJournalRepository holds the subjective cycle journal because Health Connect has no type for it, and BleDeviceRepository, PreferencesRepository and CyclePreferencesRepository own app-local device and preference state. Those are the exception. If a new repository is not backed by Health Connect, say in its KDoc why Health Connect cannot own the data.
PreferencesRepository holds every setting in the app and needs a Context, so a ViewModel that injects it cannot be built in a JVM test. Narrow contracts in data/repository/contract carve it into the groups screens actually use — PeriodPreferences, DailyGoalPreferences, BodyProfilePreferences, CalorieDisplayPreferences, SleepWindowPreferences, NutritionDisplayPreferences, HeartThresholdPreferences, HydrationGoalPreferences, BodyEnergyCalibrationPreferences, CaffeineModelPreferences, MindfulnessTimerPreferences, ActivitySplitPreferences, WidgetOrderPreferences, RecordingPreferences, UnitPreferences, OnboardingPreferences, HealthConnectPreferences — and PreferencesModule binds each to the repository. CyclePreferences is bound to CyclePreferencesRepository, a separate store in its own preferences file, because the main repository is at its size ceiling. The Settings screens and the dashboard read too wide a set to fit one, and take the repository. The reminder controllers need a Context for the same reason; HydrationReminderSettings, MindfulnessReminderSettings and CycleReminderSettings are the three-method slice a screen uses, bound in RemindersModule.
Inject the contract. The ViewModel then needs one constructor, and its test passes FakePreferences instead of mocking a hundred members. A route argument is read from the injected SavedStateHandle in that same constructor; a test builds the handle with the real argument, so activityMetricFromRoute and its routeId() inverse are the contract. Add a contract for a new group rather than widening an existing one.
Prefer APIs shaped like:
loadXPeriod(PeriodLoadQuery, featureOptions)- feature-specific query/result objects when period windows need current, previous, and baseline data
Keep granular APIs only when they are real detail or entry-list reads rather than compatibility paths for migrated screens. Avoid adding an aggregate browser layer unless product direction explicitly reintroduces one.
- period calculation and titles
- period/day navigation components
- date picker dialog
- detail-screen scaffold
- pull-to-refresh wrapper
- loading/error components
- general card primitives like
MetricCard - general chips and section headers
- metric-specific charts
- metric-specific timelines
- metric-specific list rows
- metric-specific summaries
- metric-specific empty-state language when the domain meaning differs
These are real seams in the current codebase, but they are not urgent enough to block feature work.
Several feature screens still keep route/content/cards/charts in one file.
Good future targets:
- split route/container composables from chart/card/list sections
- keep feature-specific visuals inside the feature package
- move only reusable shell pieces to
ui/components
Hydration, nutrition, heart/vitals, and body now prepare common summary values in state. Continue this pattern when a value requires sorting, grouping, or scanning a list.
For example, MetricCard.kt currently contains:
MetricCardMetricCardPlaceholderSourceChip
SectionHeader and TimeRangeSelector have since moved out into their own files, which is the direction the rest should follow if shared UI keeps growing.
Room-backed caching is intentionally narrow: the vitals daily cache, the heart-rate day averages and the beverage catalog, not raw Health Connect records.
The dashboard and daily readiness read Health Connect on every load.
The sync table (synced_record_origins) exists only because Health Connect has no record type for a record's source phone; it is not a precedent for mirroring records Health Connect can already hold.
WorkManager is used for the Apple Health import worker and the offline map import worker because those workflows can be long-running and user-visible. It is also used for one scheduled job, the home widget refresh.
A phone-to-phone sync holds its own coroutine scope, is foreground and user-initiated, and has no background variant. Do not read any of this as a general background-sync layer, and do not design new features as if one or a raw-record database already exists.
Still avoid:
- a universal chart abstraction
- a giant base ViewModel hierarchy
- premature multi-module refactors
- a full reducer/effect framework for straightforward screens
The architecture is working well when:
- a new metric screen can be added without copying shell UI
- Health Connect reads stay below the feature layer
- feature repositories stay narrow and query-oriented
- screens remain thin
- charts remain understandable because metric-specific visuals stay local
- shared extraction happens for scaffolding, not for semantics
- device protocol code stays transport-free and testable without a radio
- a new device integration adds a port implementation, not a second radio-arbitration scheme
Generated from docs/engineering/architecture.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