Own your activities, gear and health data. A privacy-first, open-source alternative to closed fitness platforms, self-hosted and yours.
HealthDart · KotlinGitHub @endurain-projectOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
Endurain mobile is the official companion app for Endurain, a self-hosted fitness tracking service.
Taken from the repository README
Loading the full README.
Read the full README ↗Mobile companion app for Endurain fitness tracking service
Visit Endurain's Mastodon profile and Discord server.
Endurain mobile app is the official companion app for Endurain, a self-hosted fitness tracking service. Built with Flutter, it provides a native mobile experience for tracking your fitness activities while maintaining full control over your data.
The app is designed with privacy in mind, connecting directly to your self-hosted Endurain server without any third-party services or analytics.
✅ Authentication
✅ Map Integration
✅ Activity Recording
Done, View history, retry, and explicit delete actionsIdempotency-Key request header. This is fully optional on the server — Endurain servers that do not recognize the header simply ignore it and uploads work unchanged, while a server that adopts it can de-duplicate automatically retried uploads with no app changes✅ Settings
diagnostics.endurain.com), independent of local diagnostics✅ Health Data Sync
Select all for the currently loaded page set, and commit them through one explicit import action; unavailable workouts remain informational rows without selection controls✅ External Sensors (Bluetooth)
universal_ble (BSD-3-Clause, no Google Play Services, F-Droid compatible); the app requests the Bluetooth runtime permissions itself✅ User Experience
🚧 Next Activity Milestones
See Local Activity Storage Design below for the current on-device storage design behind activity recording and retention.
ChangeNotifier view-model controllers wired in a composition root (AppServices) and obtained via AppScopego_router for top-level, auth-guarded routing that redirects off the session state and is ready for deep linksflutter_map and latlong2geolocator, including position streams and movement headingconnectivity_plus to retry pending activity uploads when the network returnsshare_plus for the OS share sheet used by GPX file exportflutter_secure_storagehttp for Endurain API communication and multipart uploadsapp_links for deep-link callbacks, url_launcher for the system browser OAuth flow, and flutter_svg for provider iconshealth for Apple HealthKit and Android Health Connect workout importuniversal_ble for Bluetooth Low Energy heart-rate monitors, cycling power meters, and cadence sensors (BSD-3-Clause, no Google Play Services)package_info_pluspath_provider for private app-support diagnostics and retained activity GPX storageshared_preferences for non-secret display and language settingscrypto for PKCE challenge generationsentry (pure-Dart, Sentry protocol) for remote error reports to Endurain's managed diagnostics endpoint, behind an injectable CrashReporter adaptersqflite for on-device metadata storage and for test-only in-process coveragegit clone https://github.com/endurain-project/endurain-flutter.git
cd endurain-flutter
flutter pub get
flutter gen-l10n
# For iOS
flutter run -d ios
# For Android
flutter run -d android
For Android development, install Android Studio and use its SDK Manager to install the Android SDK, Platform-Tools, Build-Tools, Command-line Tools, and the required Android platform packages. Then accept SDK licenses:
flutter doctor --android-licenses
flutter doctor -v
To run on a physical Android device, enable Developer options and USB debugging on the device, connect it with a data-capable USB cable, and accept the USB debugging authorization prompt. Verify that Flutter sees the device before running the app:
flutter devices
flutter run -d <android-device-id>
The Android build scripts use Kotlin DSL (.kts files). Flutter's Built-in Kotlin Gradle flag (android.builtInKotlin) is not yet enabled because health still self-applies KGP and is not compatible with the built-in Kotlin path. The Kotlin plugin is applied explicitly at version 2.4.0 in settings.gradle.kts instead. Once that plugin ships a compatible release, the explicit declaration can be removed and the flag enabled. Current Android builds can still show upstream plugin warnings for this dependency; if the debug build succeeds, track them with dependency updates rather than editing files in the local pub cache.
Health sync is optional. Android 14 and later include Health Connect as a system component. On earlier Android versions, the user must install the separate Health Connect provider before importing workouts. On devices where the provider is unavailable, the app keeps GPS recording and manual GPX upload available and shows health sync as unavailable.
Health sync is read-only. Endurain requests only the health data needed to find workouts and convert route-bearing workouts to GPX. Users grant access through the platform authorization screen and can revoke it later in Apple Health or Health Connect settings.
On iOS, the Runner target requires the HealthKit capability
(com.apple.developer.healthkit), NSHealthShareUsageDescription, and
NSHealthUpdateUsageDescription (required by the HealthKit integration even
though Endurain imports data only). At runtime, Endurain requests read access
to:
Endurain does not request HealthKit write access. HealthKit intentionally does not reveal individual read-grant status, so users manage those choices in the Health app or iOS Settings.
On Android, the manifest declares these Health Connect permissions:
READ_EXERCISE, to discover exercise sessions.READ_DISTANCE, READ_TOTAL_CALORIES_BURNED, and READ_STEPS, because the
pinned health package reads those records while constructing every workout
summary. Omitting any of them can make its Android workout query return an
empty list.READ_HEART_RATE, to attach available samples to GPX track points.READ_EXERCISE_ROUTES, to retrieve the GPS route. Health Connect may require
separate user consent for routes written by another app.READ_HEALTH_DATA_HISTORY, to read beyond Health Connect's standard 30-day
window. Endurain requests this only when the selected range reaches older
data and falls back to the readable recent window if the user declines.After an app update adds a new Health Connect permission, existing users must open Health sync and authorize access again; Android does not add the new grant automatically.
SSO providers must redirect back to the app with this callback URL:
endurain://auth/sso/callback
The callback currently expects a session_id query parameter, for example:
endurain://auth/sso/callback?session_id=...
Register this callback URL in the Endurain server or identity provider configuration used for mobile SSO.
For production deployments that control a public domain, verified HTTPS callbacks are the preferred mobile best practice. Android App Links and iOS Universal Links let the operating system verify that the domain is allowed to open the Endurain app, reducing custom-scheme hijacking and misrouting risk. PKCE still protects the current custom-scheme flow, but verified links provide a stronger OS-level ownership check.
Using verified callbacks requires both app and server/domain configuration:
android:autoVerify="true" App Links and iOS Associated Domains.https://<your-domain>/.well-known/assetlinks.json.https://<your-domain>/.well-known/apple-app-site-association.Keeping both callback styles during migration is recommended: continue to support endurain://auth/sso/callback for existing deployments while adding a verified HTTPS callback for domains that can publish the required .well-known files.
The app keeps a local diagnostics report to help investigate field issues when a tethered Flutter or Xcode console is not available. Open Settings > Diagnostics after relaunching the app to review recent events, captured Flutter/Dart errors, and the raw privacy-filtered report.
Diagnostics are stored only in the app's private support directory and are never uploaded automatically. The report keeps high-level lifecycle context such as app startup, activity recording start/pause/resume/stop, failure reasons, point-count milestones, and catchable Flutter/Dart errors. It intentionally avoids raw GPS coordinates and sanitizes token-like values, home/container paths, and coordinate-looking strings before saving.
Separately, opt-in remote crash reporting can forward captured Flutter/Dart errors to Endurain's managed diagnostics service. It is off by default and fully independent of local diagnostics — the user may enable neither, either, or both. Reporting uses the Sentry protocol and always targets the managed Endurain diagnostics endpoint (diagnostics.endurain.com); there is no user-configurable DSN. The same privacy redaction applied to local diagnostics scrubs error payloads before they leave the device, and nothing is transmitted until the user opts in and the managed endpoint is configured in the build.
iOS .ips crash reports remain separate system-generated native crash reports. The local diagnostics report complements them by preserving app-side context from before the crash.
Use these steps to validate provider changes on a physical or emulated Android device, in addition to the native service lifecycle tests.
Prerequisites: A running Endurain server, a connected Android device with GPS and network location enabled, and developer options active.
Expected results:
Recovery path: Re-enable location providers, then resume the existing recording.
iOS force-quit suppresses background location relaunch until the user opens the app again. Android force-stop and manufacturer battery restrictions can also stop collection. The app preserves the session and restarts when permitted; it cannot reconstruct missing GPS points. Time spent without collection is excluded on restart, using the last persisted fix as the available checkpoint.
Run static analysis:
flutter analyze
Run tests:
flutter test
CI runs formatting, Dart analysis, the Dart test suite with a coverage threshold, the Kotlin unit tests, and a debug Android build.
A separate iOS workflow builds the app for the simulator and runs the native
XCTests in ios/RunnerTests on a macOS runner. Pull requests only run it when
they change ios/**, pubspec.yaml, pubspec.lock, the workflow or its
shared actions; pushes to main always run it. It takes about 15 minutes, so
for quicker feedback while changing ios/**, build locally first:
# Compiles the Swift sources and the Xcode project configuration
flutter build ios --simulator --debug
CI does not sign the app or run it on a device, so check device-only behaviour (HealthKit, background location, entitlements) on a real iPhone.
Collect coverage and check a minimum line coverage threshold:
flutter test --coverage
dart run tool/check_coverage.dart \
--min-line-coverage 80 \
--min-file-line-coverage 60 \
--exclude "lib/l10n/app_localizations*.dart" \
--exclude "lib/core/theme/app_theme_tokens.dart" \
--exclude "lib/core/navigation/app_routes.dart" \
--exclude "lib/shared/widgets/app_bottom_nav.dart" \
--exclude "lib/core/services/platform/*.dart" \
--exclude "lib/features/health/services/health_package_platform_adapter.dart" \
--exclude "lib/features/sensors/services/universal_ble_sensor_connection_adapter.dart"
Regenerate localization classes after changing ARB files:
flutter gen-l10n
Localization source files live in lib/l10n/app_en.arb and lib/l10n/app_pt.arb. Every ARB entry must include resource attributes because l10n.yaml enables required-resource-attributes.
For a local release build:
flutter build ios --release
For TestFlight or App Store Connect, install an Apple Distribution certificate for the configured development team, then build the signed IPA:
flutter build ipa --release
# Or with ENDURAIN_CRASH_REPORTING_DSN configuration
flutter build ipa --release \
--dart-define=ENDURAIN_CRASH_REPORTING_DSN='https://<key>@diagnostics.endurain.com/<project>'
Upload build/ios/ipa/Endurain.ipa with Transporter or Xcode Organizer. Before
each upload, increment the Flutter build number in pubspec.yaml; App Store
Connect requires a unique, increasing build number.
Debug builds do not require release signing credentials:
flutter build apk --debug
Release builds require Android signing configuration. Copy
android/key.properties.example to ignored android/key.properties and fill it locally, or provide the documented ANDROID_KEYSTORE_* environment variables in CI. A release build now fails rather than falling back to the debug key when any signing input is missing.
GitHub Actions release builds require these repository secrets:
ANDROID_KEYSTORE_BASE64: base64-encoded upload keystore.ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, and ANDROID_KEY_PASSWORD: upload-key credentials.ANDROID_UPLOAD_CERT_SHA256: expected SHA-256 certificate fingerprint, with or without colon separators.For a published release, tag the commit as vX.Y.Z+BUILD mirroring pubspec's version: field (for example v0.8.0+11). CI splits the tag into the Android versionName (0.8.0) and versionCode (11), and fails the build if the tag does not match pubspec.yaml. Because Google Play requires a strictly increasing versionCode, bump the build number in pubspec.yaml before tagging. Manual workflow runs instead require an explicit semantic version_name and positive, monotonically increasing version_code.
Release builds may also bake in the managed diagnostics DSN for opt-in remote crash reporting. Set the optional GitHub repository variable ENDURAIN_CRASH_REPORTING_DSN (a Sentry/GlitchTip DSN — a public, send-only client identifier, so it is a variable rather than a secret). CI passes it to the build as --dart-define=ENDURAIN_CRASH_REPORTING_DSN=.... It is intentionally not hardcoded in source, so builds from source (forks, F-Droid) omit it and never point crash reports at the Endurain-operated endpoint by default. Remote crash reporting always targets this managed endpoint when the user opts in — there is no user-configurable DSN. When the variable is unset, the build simply ships no managed default and opt-in remote crash reporting stays inactive. For local runs or local/iOS release builds, pass the same flag to flutter run or flutter build:
# Run a release build on a connected device with the DSN baked in
flutter run -d <device-id> --release \
--dart-define=ENDURAIN_CRASH_REPORTING_DSN='https://<key>@diagnostics.endurain.com/<project>'
# Or build a release artifact
flutter build apk --release \
--dart-define=ENDURAIN_CRASH_REPORTING_DSN='https://<key>@diagnostics.endurain.com/<project>'
To avoid repeating long values (and to keep them out of shell history), put your --dart-define values in a git-ignored JSON file and pass --dart-define-from-file=dart-defines.json instead:
// dart-defines.json (git-ignored)
{
"ENDURAIN_CRASH_REPORTING_DSN": "https://<key>@diagnostics.endurain.com/<project>"
}
flutter build apk --release
# or for App Bundle
flutter build appbundle --release
lib/
├── main.dart # App entry point
├── app.dart # Root app widget
├── core/
│ ├── config/ # App configuration and environment helpers
│ ├── constants/ # App-wide constants (API, UI, map)
│ ├── models/ # Shared app models and exception types
│ ├── navigation/ # Route names and the go_router auth-guard config
│ ├── services/ # API, auth, storage, links, location, package info
│ ├── theme/ # Theme configuration and tokens
│ └── utils/ # Validators, dialogs, error localization, platform helpers
├── features/
│ ├── activity/ # Recording controllers, models, repositories, services, screens, and widgets
│ ├── auth/ # Login, MFA, SSO, and session controllers
│ ├── health/ # HealthKit/Health Connect import, sync, and access screens
│ ├── map/ # Map screen, map settings, and location state
│ ├── sensors/ # Bluetooth heart-rate sensor pairing and live BPM
│ └── settings/ # Settings and server configuration screens
├── shared/
│ ├── adaptive/ # Material/Cupertino adaptive components
│ ├── state/ # Shared state helpers and controllers
│ └── widgets/ # Shared app widgets
└── l10n/ # ARB files and generated localizations
test/
├── architecture/ # Architecture and integration-style tests
├── core/ # Unit tests for services, models, and utilities
├── features/ # Feature unit and widget tests
├── helpers/ # Test fakes and widget harnesses
├── l10n/ # Localization and resource tests
├── shared/ # Adaptive widget tests
└── tool/ # Tooling tests, including coverage checker tests
tool/
└── check_coverage.dart # LCOV coverage threshold utility
Activities are recorded and saved on-device for upload to the server. Metadata is stored in SQLite (via SqfliteActivityStore), while GPX tracks remain as files on disk.
Completed recordings are stored under the app's private support directory:
<app-support>/
└── activity_records/
└── gpx/
└── <activity-id>.gpx # Raw GPX 1.1 file for each recording
Activity metadata lives in a SQLite database (activity.db, schema v9) under the platform databases directory. The database holds two tables:
| Table | Purpose |
|---|---|
schema_version | Single-row version counter used to gate migrations. |
local_activity | One row per activity, all metadata columns. |
Each local_activity row contains: id, activity_type, started_at, ended_at, elapsed_duration_seconds, distance_meters, average_speed_meters_per_second, max_speed_meters_per_second, elevation_gain_meters, point_count, gpx_file_name, upload_status, idempotency_key, connection_origin, connection_profile_id, auto_retry_eligible, gpx_cleanup_pending, created_at, updated_at, uploaded_at, last_upload_attempt_at, and last_upload_error_code. GPX files live on disk under gpx/ — only the metadata is stored in the database. Health-imported activities are additionally tracked for provenance and de-duplication in a separate health_import.db (schema v3).
The native recorder owns collection and the private active-session log. Flutter recovery attaches without pausing a live recorder. A stopped recorder restarts only sessions marked recording; explicitly paused and failed sessions stay paused until user action. Only a session marked completed or stopping by an explicit Stop is finalized on recovery, preserving interrupted save/upload retries.
Point events carry the local session ID and an offset into the valid persisted-point log. Flutter subscribes before recovery, buffers events during the initial drain, skips replayed offsets, and drains a missing range before accepting later batches. Events from another session are ignored. Channel payload version 2 is shipped together on Dart, Android, and iOS; the durable session and database schemas are unchanged.
On iOS, Dart-owned BLE readings are durably appended to sensors.jsonl in the same private active-session directory before being exposed as live values. The log is scoped to the recording ID, restored before rebuilding GPS points, and removed with the recording. Pause, stop, and discard wait for accepted sensor writes; write or read failures retain a recoverable error state instead of reporting success. This preserves readings captured before process termination, but cannot reconstruct measurements never delivered while the app was terminated. Older recordings without a sensor log remain recoverable.
Native tests exercise timing and collection independently of a real GPS receiver. Android uses test-only Robolectric with resources enabled; host-test packaging explicitly depends on Flutter asset generation for the same variant. Run cd android && ./gradlew :app:testDebugUnitTest and the iOS RunnerTests scheme in addition to flutter test.
Schema changes are applied through an ordered, append-only migration map keyed by target version. Fresh installs (onCreate) and upgrades (onUpgrade) run the same migration steps, so the schema is produced by exactly one code path. To evolve the schema, append a new migration and bump the schema version — shipped migrations are never edited.
Chosen: sqflite (with sqflite_common_ffi for tests only)
| Package | License | Reason |
|---|---|---|
sqflite | Apache 2.0 | De facto Flutter SQLite; Android/iOS native |
sqflite_common_ffi | MIT | In-process SQLite for unit tests (dev only) |
sqflite provides the shipped Android/iOS implementation, while sqflite_common_ffi is a dev_dependency that runs the same API in-process during unit tests without platform-channel mocks.
drift (an ORM built on SQLite) was considered but rejected — the direct SQL API of sqflite is sufficient for the current schema and avoids an additional code-generation dependency.
Contributions are welcomed! This mobile app is part of the main Endurain project. Please:
AppLocalizations (l10n)core/constants/PlatformUtils for platform checks.github/copilot-instructions.mdThis project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0-only). See LICENSE for the full terms. AGPLv3 is a strong copyleft license: anyone distributing modified versions, or operating modified network-server software, must make the corresponding source available under AGPLv3 terms. The mobile app itself is distributed software; the related Endurain server has its own licensing and source-availability obligations.
Accepted external contributions are also subject to the Contributor License Agreement, which gives the Project Owner the additional right to relicense those contributions in future releases. The Project Owner will preserve the rights already granted to the public for releases made under AGPLv3.
Endurain® is a trademark of João Vitória Silva.
You are welcome to self-host Endurain and use the name and logo, including for personal, educational, research, or community (non-commercial) use.
Commercial use of the Endurain name or logos (such as offering paid hosting, products, or services) is not permitted without prior written permission.
See TRADEMARK.md for full details.
TextToSpeechAVSpeechSynthesizersqflite_common_ffiflutter_lints with strict casts, strict inference, strict raw types, and additional lint rules