diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index cf3be87d..ea0a240a 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -36,6 +36,13 @@ jobs:
run: |
echo "COMPANION_URL=${{ secrets.COMPANION_URL }}" > .env
echo "BACKEND_URL=${{ secrets.BACKEND_URL }}" >> .env
+ # Health-data contribution (full on-device DB upload) is OFF for
+ # every build we distribute, sideload included — matching
+ # PRIVACY.md's "we do not collect your data". Passed explicitly
+ # (not just relying on kHealthDataContributionEnabled's default)
+ # so this is true by construction of the CI config itself, not
+ # contingent on nobody ever changing that default later.
+ echo "ENABLE_HEALTH_DATA_CONTRIBUTION=false" >> .env
# Decode the release keystore from a secret. The gradle config picks these
# env vars up (ANDROID_KEYSTORE_PATH etc.) and signs the release build.
@@ -127,6 +134,13 @@ jobs:
run: |
echo "COMPANION_URL=${{ secrets.COMPANION_URL }}" > .env
echo "BACKEND_URL=${{ secrets.BACKEND_URL }}" >> .env
+ # Health-data contribution (full on-device DB upload) is OFF for
+ # every build we distribute, sideload included — matching
+ # PRIVACY.md's "we do not collect your data". Passed explicitly
+ # (not just relying on kHealthDataContributionEnabled's default)
+ # so this is true by construction of the CI config itself, not
+ # contingent on nobody ever changing that default later.
+ echo "ENABLE_HEALTH_DATA_CONTRIBUTION=false" >> .env
- name: Install flutterfire (required by Crashlytics Xcode build phase)
run: dart pub global activate flutterfire_cli 1.4.0
diff --git a/NOTICE.md b/NOTICE.md
new file mode 100644
index 00000000..97c3ad5d
--- /dev/null
+++ b/NOTICE.md
@@ -0,0 +1,11 @@
+# Notice
+
+Edge is an independent, open-source project (MIT License — see `LICENSE`). It
+is not affiliated with, sponsored by, or endorsed by WHOOP, Inc. or any of its
+trademarks.
+
+No WHOOP source code, binaries, firmware, or copyrighted assets are included
+in this repository. The Bluetooth protocol support in this project was
+independently developed by observing the band's own Bluetooth communications;
+see [the protocol repo's README](https://github.com/OpenStrap/protocol) for
+methodology notes.
diff --git a/PRIVACY.md b/PRIVACY.md
new file mode 100644
index 00000000..f1430749
--- /dev/null
+++ b/PRIVACY.md
@@ -0,0 +1,87 @@
+# Privacy Policy — Edge / OpenStrap
+
+_Last updated: July 18, 2026_
+
+Edge ("the App") is an independent, open-source project. It is not affiliated
+with, sponsored by, or endorsed by WHOOP, Inc.
+
+**We do not collect your data**
+This section describes every build we ourselves distribute — the signed
+Android APK and iOS IPA published on GitHub Releases, and any future App
+Store/Play Store submission. Your biometric/health data from your paired
+WHOOP 4.0 band — heart rate, heart rate variability (RR intervals), motion
+(accelerometer), raw skin-temperature/blood-oxygen sensor channels, and (if
+you record workouts) GPS location during those workouts — is processed and
+stored entirely on your device in every one of those builds. We do not
+upload it, we do not operate a backend that receives it, and we never see it
+— *unless you separately choose to enable AI Coach or Health app
+integration, described below, which send specific data to services you
+configure.*
+
+Edge is open source. The underlying code contains an off-by-default,
+compile-time flag (`kHealthDataContributionEnabled`, see
+`lib/telemetry/health_uploader.dart`) that an independent developer could
+enable in their *own*, separately-built and separately-distributed copy of
+the app, pointed at a backend of their own choosing. No build we publish
+enables it — our CI explicitly forces it off for every release (see
+`.github/workflows/build.yml`) rather than relying on the flag's default. A
+self-built copy compiled with that flag on is that builder's own software
+and their own responsibility; it is not covered by this policy.
+
+**Anonymous diagnostics (on by default)**
+In every build we distribute, the only thing the App sends off your device
+*automatically* — aside from the optional, user-initiated integrations
+described next — is basic crash/error and performance monitoring, via
+Firebase (Google) — Crashlytics, Performance Monitoring, and Analytics. This
+is on by default; you can turn it off at any
+time in Settings ("Send anonymous diagnostics"), which stops any further
+collection immediately. It never includes your health data — only crash
+reports, basic device info (OS/model/app version), and coarse performance
+timing. This data is handled under Firebase's own privacy and security
+practices, not a system we built or operate ourselves — see Google's Firebase
+privacy & security documentation: https://firebase.google.com/support/privacy.
+
+**Optional, user-initiated integrations**
+If you choose to enable them, the App can also send data to services *you*
+configure:
+- **AI Coach** — if you enable this feature and supply your own API key,
+ summaries of your data are sent to the AI provider you configure (by
+ default, OpenAI) to generate coaching responses. Off by default and
+ requires your own API key.
+- **Health app integration** — if you enable it, the App can write derived
+ daily metrics to Apple Health or Google Health Connect, which are controlled
+ by your device's own OS-level health app, not by us.
+
+**What we don't do**
+We do not sell your data. We do not send your health data to WHOOP, Inc. or
+any advertising network. We do not require a WHOOP account or credentials to
+use the App. We do not operate a backend that stores your health data.
+
+**Your controls**
+Turn off "Send anonymous diagnostics" at any time in Settings — this stops any
+further collection immediately. You can also disable AI Coach or Health app
+integration at any time in Settings if you'd previously turned them on.
+
+Uninstalling the App deletes all of your locally stored data immediately.
+That's the whole picture *unless* you had separately enabled one of the
+optional integrations above — in that case, uninstalling stops the App from
+sending anything further, but does not reach back and delete data already
+sent:
+- Anonymous diagnostics already sent to Firebase are retained and governed by
+ Firebase's own practices (linked above), not by us.
+- Data already sent to your configured AI Coach provider (e.g. OpenAI) is
+ retained and governed by that provider's own policies, not by us.
+- Metrics already written to Apple Health or Google Health Connect are
+ retained and governed by that platform's own data controls, not by us —
+ manage or delete them from that app directly.
+
+**Children**
+This App is not directed to children under 13 (or the relevant age of digital
+consent in your jurisdiction) and we do not knowingly collect data from them.
+
+**Changes**
+We may update this policy; material changes will be reflected here with an
+updated date.
+
+**Contact**
+Questions about this policy: abdulsaheel81@gmail.com.
diff --git a/docs/privacy.html b/docs/privacy.html
index c4791acb..2196718c 100644
--- a/docs/privacy.html
+++ b/docs/privacy.html
@@ -26,26 +26,51 @@
Privacy Policy — Edge / OpenStrap
Edge ("the App") is an independent, open-source project. It is not affiliated
with, sponsored by, or endorsed by WHOOP, Inc.
- Data we collect
-
- - Biometric/health data from your paired WHOOP 4.0 band over Bluetooth: heart
- rate, heart rate variability (RR intervals), motion (accelerometer), raw
- skin-temperature/blood-oxygen sensor channels, and (if you record workouts) GPS
- location during those workouts.
- - Basic app usage/crash telemetry, only if you opt in.
-
+ We do not collect your data
+ This section describes every build we ourselves distribute — the signed
+ Android APK and iOS IPA published on GitHub Releases, and any future App
+ Store/Play Store submission. Your biometric/health data from your paired
+ WHOOP 4.0 band — heart rate, heart rate variability (RR intervals), motion
+ (accelerometer), raw skin-temperature/blood-oxygen sensor channels, and (if
+ you record workouts) GPS location during those workouts — is processed and
+ stored entirely on your device in every one of those builds. We do not
+ upload it, we do not operate a backend that receives it, and we never see
+ it — unless you separately choose to enable AI Coach or Health app
+ integration, described below, which send specific data to services you
+ configure.
+
+ Edge is open source. The underlying code contains an off-by-default,
+ compile-time flag (kHealthDataContributionEnabled) that an
+ independent developer could enable in their own, separately-built
+ and separately-distributed copy of the app, pointed at a backend of their
+ own choosing. No build we publish enables it — our CI explicitly forces it
+ off for every release rather than relying on the flag's default. A
+ self-built copy compiled with that flag on is that builder's own software
+ and their own responsibility; it is not covered by this policy.
+
+ Anonymous diagnostics (on by default)
+ In every build we distribute, the only thing the App sends off your device
+ automatically — aside from the optional, user-initiated
+ integrations described next — is basic crash/error and performance
+ monitoring, via Firebase (Google) — Crashlytics, Performance Monitoring, and
+ Analytics. This is on by default; you can turn it off at any time in
+ Settings ("Send anonymous diagnostics"),
+ which stops any further collection immediately. It never includes your
+ health data — only crash
+ reports, basic device info (OS/model/app version), and coarse performance
+ timing. This data is handled under Firebase's own privacy and security
+ practices, not a system we built or operate ourselves — see Google's Firebase
+ privacy & security documentation:
+ firebase.google.com/support/privacy.
- Where your data lives
- By default, all of the above is stored locally on your device only. We do not
- upload it anywhere unless you explicitly opt in to one of the following:
+ Optional, user-initiated integrations
+ If you choose to enable them, the App can also send data to services
+ you configure:
- - Cloud backup — a one-time or periodic encrypted upload of
- your local database to our own backend, only if you turn this on in
- Settings.
- AI Coach — if you enable this feature and supply your own
API key, summaries of your data are sent to the AI provider you configure (by
- default, OpenAI) to generate coaching responses. This is off by default and
- requires your own API key.
+ default, OpenAI) to generate coaching responses. Off by default and requires
+ your own API key.
- Health app integration — if you enable it, the App can
write derived daily metrics to Apple Health or Google Health Connect, which
are controlled by your device's own OS-level health app, not by us.
@@ -54,18 +79,28 @@ Where your data lives
What we don't do
We do not sell your data. We do not send your health data to WHOOP, Inc. or any
advertising network. We do not require a WHOOP account or credentials to use the
- App.
+ App. We do not operate a backend that stores your health data.
Your controls
- You can disable cloud backup, telemetry, or AI Coach at any time in Settings.
- Uninstalling the App deletes all of its local data from your device — there is
- no separate local-data export left behind. In-app, Settings → "Reset profile"
- clears your local profile (name, age, body metrics) and forgets your paired
- band, but does not delete your previously stored band data on the device; full
- local deletion currently requires uninstalling the App. If you opted in to
- cloud backup, we do not yet have a self-serve deletion flow for that uploaded
- copy — email us at abdulsaheel81@gmail.com to
- request its deletion and we will process it manually.
+ Turn off "Send anonymous diagnostics" at any time in Settings — this stops any
+ further collection immediately. You can also disable AI Coach or Health app
+ integration at any time in Settings if you'd previously turned them on.
+
+ Uninstalling the App deletes all of your locally stored data immediately.
+ That's the whole picture unless you had separately enabled one of
+ the optional integrations above — in that case, uninstalling stops the App
+ from sending anything further, but does not reach back and delete data
+ already sent:
+
+ - Anonymous diagnostics already sent to Firebase are retained and
+ governed by Firebase's own practices (linked above), not by us.
+ - Data already sent to your configured AI Coach provider (e.g. OpenAI)
+ is retained and governed by that provider's own policies, not by
+ us.
+ - Metrics already written to Apple Health or Google Health Connect are
+ retained and governed by that platform's own data controls, not by us
+ — manage or delete them from that app directly.
+
Children
This App is not directed to children under 13 (or the relevant age of digital
diff --git a/docs/terms.html b/docs/terms.html
index be56a716..9d548cc4 100644
--- a/docs/terms.html
+++ b/docs/terms.html
@@ -62,10 +62,13 @@
5. Your Responsibilities
6. Third-Party Services
- Certain optional features rely on third-party services you separately
+
The App uses Firebase (Google) for anonymous crash/error and performance
+ monitoring (Crashlytics, Performance Monitoring, Analytics). This is on by
+ default and can be turned off at any time in Settings — see the Privacy
+ Policy for details. It never includes your health data.
+ Certain other, optional features rely on third-party services you separately
configure or consent to, including:
- - An optional cloud backup feature, self-hosted by the App's maintainers.
- An optional AI Coach feature, which sends data you choose to share to a
third-party AI provider (by default, OpenAI) using an API key you
supply.
diff --git a/guides/IOS_INSTALLATION.md b/guides/IOS_INSTALLATION.md
index 8bb1ed47..f3b663fe 100644
--- a/guides/IOS_INSTALLATION.md
+++ b/guides/IOS_INSTALLATION.md
@@ -126,6 +126,26 @@ For a signed release-style device install:
flutter run --release -d --dart-define-from-file=.env
```
+## Version Numbers
+
+`Runner` tracks `pubspec.yaml`'s `version:` automatically via `Info.plist`'s
+`CFBundleShortVersionString`/`CFBundleVersion` keys, which point at
+`$(FLUTTER_BUILD_NAME)`/`$(FLUTTER_BUILD_NUMBER)` (Flutter writes these into
+`ios/Flutter/Generated.xcconfig` on every build). Runner's own
+`CURRENT_PROJECT_VERSION` build setting also resolves to `$(FLUTTER_BUILD_NUMBER)`;
+it does not set a `MARKETING_VERSION` build setting at all — the marketing
+version comes solely from the `Info.plist` key above.
+
+The **`OpenStrapWidget`/`OpenStrapWidgetExtension`** and **`OpenStrapWatch Watch
+App`** targets are NOT wired to that mechanism — they don't include
+`Generated.xcconfig`, so those Flutter variables aren't available to them, and
+`MARKETING_VERSION`/`CURRENT_PROJECT_VERSION` are hardcoded per-target in
+`project.pbxproj` instead. **Bump these by hand to match `pubspec.yaml`'s
+`version:` every time it changes** — App Store Connect validates that a host
+app's embedded extensions and companion Watch app carry compatible version
+numbers, so letting these drift is a real upload-time risk, not just cosmetic
+inconsistency.
+
## Debug Builds and Home-Screen Relaunch
Flutter debug builds on iOS must be launched by Flutter tooling or Xcode. If you
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index 4329a129..bee5ce53 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -18,7 +18,6 @@
5348973F2FDC19C80033A4D9 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5348973E2FDC19C80033A4D9 /* SwiftUI.framework */; };
5348974E2FDC19C90033A4D9 /* OpenStrapWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5348973B2FDC19C80033A4D9 /* OpenStrapWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
534897A72FDC2B310033A4D9 /* LiveActivityBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 534897A62FDC2B310033A4D9 /* LiveActivityBridge.swift */; };
- BEEF00000000000000000002 /* BreathingLiveActivityBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF00000000000000000001 /* BreathingLiveActivityBridge.swift */; };
53962E962FF6EE120061A61B /* WatchBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53962E952FF6EE120061A61B /* WatchBridge.swift */; };
53962E972FF6EE120061A61B /* OpenStrapIntents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53962E942FF6EE120061A61B /* OpenStrapIntents.swift */; };
630172CC9317145AD5F8F3B7 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2C721EA0C8D31A3834E66203 /* Pods_RunnerTests.framework */; };
@@ -28,6 +27,7 @@
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
ACCE55E7000000000000B11D /* AccessorySetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACCE55E7000000000000F11E /* AccessorySetup.swift */; };
+ BEEF00000000000000000002 /* BreathingLiveActivityBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = BEEF00000000000000000001 /* BreathingLiveActivityBridge.swift */; };
FADE0001FADE0001FADE0001 /* OpenStrapWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 53962EBE2FF6EF790061A61B /* OpenStrapWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
@@ -105,7 +105,6 @@
534897562FDC1A450033A4D9 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; };
534897572FDC1A610033A4D9 /* OpenStrapWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenStrapWidgetExtension.entitlements; sourceTree = ""; };
534897A62FDC2B310033A4D9 /* LiveActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityBridge.swift; sourceTree = ""; };
- BEEF00000000000000000001 /* BreathingLiveActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreathingLiveActivityBridge.swift; sourceTree = ""; };
53962E942FF6EE120061A61B /* OpenStrapIntents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenStrapIntents.swift; sourceTree = ""; };
53962E952FF6EE120061A61B /* WatchBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchBridge.swift; sourceTree = ""; };
53962EBE2FF6EF790061A61B /* OpenStrapWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "OpenStrapWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -128,6 +127,7 @@
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
ACCE55E7000000000000F11E /* AccessorySetup.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessorySetup.swift; sourceTree = ""; };
B0B95B9F07C8083A2DB92078 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; };
+ BEEF00000000000000000001 /* BreathingLiveActivityBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BreathingLiveActivityBridge.swift; sourceTree = ""; };
DE1829A4D85DB1C3222C2CB5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; };
EC2DC396945E3B2DAA52EB3F /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; };
FE8EE10565E740E87BE6E423 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; };
@@ -833,7 +833,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -849,7 +849,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 0.9.14;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)";
@@ -880,7 +880,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -896,7 +896,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 0.9.14;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -924,7 +924,7 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = OpenStrapWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 1;
+ CURRENT_PROJECT_VERSION = 47;
DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -940,7 +940,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 0.9.14;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_WIDGET_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -971,8 +971,8 @@
CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 17;
- DEVELOPMENT_TEAM = 2U62X3RF3R;
+ CURRENT_PROJECT_VERSION = 47;
+ DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -987,7 +987,7 @@
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 0.7.0;
+ MARKETING_VERSION = 0.9.14;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp";
@@ -1026,8 +1026,8 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 17;
- DEVELOPMENT_TEAM = 2U62X3RF3R;
+ CURRENT_PROJECT_VERSION = 47;
+ DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1042,7 +1042,7 @@
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 0.7.0;
+ MARKETING_VERSION = 0.9.14;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -1077,8 +1077,8 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "OpenStrapWatch Watch App/OpenStrapWatch Watch App.entitlements";
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 17;
- DEVELOPMENT_TEAM = 2U62X3RF3R;
+ CURRENT_PROJECT_VERSION = 47;
+ DEVELOPMENT_TEAM = "$(APPLE_DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -1093,7 +1093,7 @@
"@executable_path/Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MARKETING_VERSION = 0.7.0;
+ MARKETING_VERSION = 0.9.14;
MTL_FAST_MATH = YES;
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER).watchkitapp";
PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json
new file mode 100644
index 00000000..19292716
--- /dev/null
+++ b/ios/Runner/Assets.xcassets/LaunchBackground.colorset/Contents.json
@@ -0,0 +1,38 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xEC",
+ "green" : "0xF1",
+ "red" : "0xF4"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x0D",
+ "green" : "0x11",
+ "red" : "0x14"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
index 9da19eac..bf1b93a1 100644
Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
index 9da19eac..664f151c 100644
Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
index 9da19eac..89f9b6b7 100644
Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard
index f2e259c7..ad5c6556 100644
--- a/ios/Runner/Base.lproj/LaunchScreen.storyboard
+++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -19,7 +19,7 @@
-
+
@@ -32,6 +32,6 @@
-
+
diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist
index 524c5da4..2fcc9d28 100644
--- a/ios/Runner/Info.plist
+++ b/ios/Runner/Info.plist
@@ -44,6 +44,21 @@
OpenStrap connects to your WHOOP band over Bluetooth to sync your health data.
NSLocationWhenInUseUsageDescription
OpenStrap records your route on a map during a run, ride or walk so you can see it, colored by heart-rate zone, when the workout ends. Your location stays on this device and is never uploaded.
+
+ NSLocationAlwaysAndWhenInUseUsageDescription
+ OpenStrap does not track your location in the background. This permission is linked by a dependency but unused — the app only ever asks for location access while you're actively viewing a workout route.
+
+ NSPhotoLibraryUsageDescription
+ OpenStrap does not access your photo library. This permission is linked by a dependency but unused — importing your own exported WHOOP data uses the Files app, not Photos.
NSSupportsLiveActivities
OpenStrapAppGroupIdentifier
diff --git a/lib/compute/crossday_pipeline.dart b/lib/compute/crossday_pipeline.dart
index 5c70e3cb..e3931c95 100644
--- a/lib/compute/crossday_pipeline.dart
+++ b/lib/compute/crossday_pipeline.dart
@@ -222,8 +222,8 @@ Map buildCrossDayBundle(
tsb: ls?.tsb,
);
- // ── VO₂max + WHOOP-Age (physiological age). Resting VO₂max (Uth HRmax:RHR)
- // over the baseline RHR; WHOOP-Age composites VO₂max + RHR + HRV + sleep +
+ // ── VO₂max + Fitness Age (physiological age). Resting VO₂max (Uth HRmax:RHR)
+ // over the baseline RHR; Fitness Age composites VO₂max + RHR + HRV + sleep +
// steps vs age-norms. All ESTIMATE, absent on missing inputs.
final age = _numOrNull(profile['age']);
final sexStr = (profile['sex'] as String?)?.toLowerCase();
@@ -301,7 +301,7 @@ Map buildCrossDayBundle(
},
'strain_coach': strainTgt.toJson((v) => v.toJson()),
'vo2max': vo2.toJson(),
- 'whoop_age': physAge.toJson((v) => v.toJson()),
+ 'fitness_age': physAge.toJson((v) => v.toJson()),
'percentiles': percentiles,
'recent': recent,
};
diff --git a/lib/compute/derivation_engine.dart b/lib/compute/derivation_engine.dart
index 511e337b..c8549ac4 100644
--- a/lib/compute/derivation_engine.dart
+++ b/lib/compute/derivation_engine.dart
@@ -101,7 +101,7 @@ import 'substrate.dart';
/// also tunes this estimate. Bumping re-derives non-finalized days so they fill.
// v20: principled nap detection (van Hees immobility + HR-dip) → `naps` block +
// `nap_min` scalar; cross-day Sleep Coach (need/bedtime/cycle-wake/performance),
-// Strain Coach (recovery-gated target), VO₂max + WHOOP-Age, all in the crossday
+// Strain Coach (recovery-gated target), VO₂max + Fitness Age, all in the crossday
// bundle.
// v21: all-day HRV line (`series.hrv_day`, epoch rolling RMSSD over 24/7 RR).
// v22: all-day RESP line (`series.resp_day`, rolling RSA br/min) + relative
@@ -210,7 +210,28 @@ import 'substrate.dart';
// over time while per-night-local always leads. Deep stays a low-confidence
// NREM sub-split (deep_low_confidence). The profile self-seeds across this
// re-derivation sweep; no explicit migration. Bump so every day re-stages.
-const int kAlgoVersion = 42;
+// v43: readinessComposite now falls back to a mean/SD z when the robust (median
+// +MAD) z is degenerate (MAD==0 on a tightly-clustered quantized baseline —
+// whole-bpm RHR / integer skin-temp ADC), which was intermittently blanking the
+// whole readiness score to "—" on nights that had valid sleep. Bump so days that
+// were previously absent-for-that-reason recompute a real score.
+// v44: two consistency fixes; neither changes a scalar that a previously
+// FINALIZED day_result already had right, but both affect data availability/
+// consistency going forward. (1) A day whose offloaded second-half compute
+// (naps/workouts/HRR/wear/curves/wake-features) failed or timed out — but
+// whose headline scalars (readiness/RHR/RMSSD) already succeeded — could get
+// marked finalized and treated as fully "derived" by the raw-pruning guard,
+// permanently losing the raw substrate needed to ever fill in those missing
+// fields on retry. Now tracked via a new `partial` day_result column and
+// excluded from both age-based finalization and the pruning guard until the
+// second half actually completes. (2) The wake_day_features early-read
+// artifact (what the Today repo shows before the full day result is ready)
+// was copying the pre-hybrid-correction 1Hz-only step/calorie estimate
+// instead of the corrected real-100Hz+1Hz hybrid value computed moments
+// later in the same pass — the final day_result was always correct, only
+// this transient early read was stale. Bump so any day currently sitting
+// non-finalized re-derives with both fixes in effect.
+const int kAlgoVersion = 44;
/// Raw is kept this many days past derivation, then pruned (derived stays).
const int rawRetentionDays = 3;
@@ -1397,6 +1418,28 @@ class DerivationEngine {
message: 'readiness_absent',
context: flat,
);
+ // Also surface the SURPRISING case — readiness absent when it should NOT
+ // be (adequate inputs, not the honest cold-start `need_baseline` note) —
+ // as a queryable Crashlytics non-fatal, so a residual "readiness '—' even
+ // with sleep present" is diagnosable from the per-input flags WITHOUT GA4
+ // access. Cold-start (need_baseline) absences stay Analytics-only so this
+ // stays low-noise (and, post the MAD/SD-z fallback, rare).
+ final note = (diag['note'] as String?) ?? '';
+ if (!note.startsWith('need_baseline')) {
+ final summary = StringBuffer('readiness_absent');
+ for (final key in ['hrv', 'rhr', 'resp', 'temp']) {
+ final v = (diag[key] as Map?)?.cast();
+ if (v == null) continue;
+ summary.write(
+ ' $key=${v['value'] == true ? 'Y' : 'n'}/${v['baseline_n']}');
+ }
+ summary.write(' | $note');
+ TelemetryService.instance.recordNonFatal(
+ StateError(summary.toString()),
+ StackTrace.current,
+ reason: 'readiness_absent',
+ );
+ }
}
// Where this day's sleep window came from (auto / auto_fallback / manual /
@@ -1405,149 +1448,120 @@ class DerivationEngine {
bundle['sleep_source'] = day.sleepSource;
final scMap = (bundle['scalars'] as Map?)?.cast();
- final wake = _buildWakeDayFeatures(
- daySub,
- profile,
- sleepOnsetSec: day.sleepOnsetSec,
- sleepOffsetSec: day.sleepOffsetSec,
- restingHr: (scMap?['rhr'] as num?)?.toDouble(),
- );
- _applyWakeDayFeatures(bundle, scMap, wake);
-
- // ── STEPS (real 100 Hz + 1 Hz estimate) + TOTAL DAILY ENERGY (TDEE) ───────
- // Pull the day's 100 Hz coverage windows (device-time sec) + their real step
- // count so _stepsAndEnergy can prefer them and estimate only the rest.
- final dayLo = daySub.length == 0 ? 0 : daySub.tsSec.first;
- final dayHi = daySub.length == 0 ? 0 : daySub.tsSec.last + 60;
- final coverageWindows = await LocalDb.coverageWindowsOverlapping(
- dayLo,
- dayHi,
- );
- final liveStepsReal = await LocalDb.liveStepsForDay(day.date);
- final stepCalib = await LocalDb.getStepCalibration();
- _stepsAndEnergy(
- bundle,
- scMap,
- daySub,
- profile,
- coverageWindows,
- liveStepsReal,
- stepCalib,
- );
- // ── More substrate-derived detail blocks (computed here, where the full
- // sliced substrate lives — same pattern as activeMin; these are fresh
- // blocks so ints are safe, unlike the double? scalars). ─
- bundle['daytime_hrv'] = _daytimeHrv(
- daySub,
- day.sleepOnsetSec,
- day.sleepOffsetSec,
- ); // waking RMSSD
- // ALL-DAY HRV / resp / skin-temp lines for the Timeline graph. Epoch-stamped
- // (rolling RMSSD, rolling RSA, relative skin-temp) over the day-wide 24/7
- // substrate RR/ADC — context lines, movement-confounded, not the recovery
- // values. Computed here where the day substrate lives.
- final sersMap = (bundle['series'] as Map?)?.cast();
- sersMap?['hrv_day'] = _dayHrvCurve(daySub);
- sersMap?['resp_day'] = _dayRespCurve(daySub);
- sersMap?['skin_temp_day'] = _daySkinTempCurve(daySub);
- bundle['restlessness'] = _restlessness(sleepSub); // nocturnal movement
- bundle['sleep_periods'] = _sleepPeriods(
- daySub,
- day.sleepOnsetSec,
- day.sleepOffsetSec,
- ); // naps
- // Principled daytime NAPS (van Hees immobility + HR-dip): rich per-nap block
- // + a `nap_min` scalar feeding the Sleep Coach's nap credit + the Timeline.
- _attachNaps(bundle, scMap, daySub, day.sleepOnsetSec, day.sleepOffsetSec);
-
- // Per-5-min movement-level curve for the "Your day" Movement view ([{t,v}],
- // v = fraction of moving seconds 0..1). Fresh top-level list — no typed-map.
- bundle['activity_curve'] = _activityCurve(daySub);
-
- // ── AUTO-DETECTED WORKOUTS (WorkoutDetector, 1 Hz HR + gravity) ──────────
- // Retroactive bout detection over the day's HR + gravity (lives in daySub,
- // not the bundle input). Per-bout strain/zones/calories via the analytics
- // family. OVERLAP-DEDUP: any manual/live session for this day is passed as a
- // saved span so a detected bout overlapping it is dropped (manual wins). The
- // sport seam stays default ("detected") — OpenStrap's HAR typer can be wired
- // in once high-rate accel features exist.
- bundle['detected_workouts'] = await _detectedWorkouts(daySub, day, profile);
-
- // ── AUTO-WORKOUT SUGGESTIONS + HRR (opt-in detector over day HR+motion) ──
- // One pass of the opt-in detector serves two features: (1) persisted "did you
- // work out?" suggestions + a recent-day notification, and (2) heart-rate
- // recovery (HRR) per bout → `hrr_bpm` scalar. scMap writes through to
- // bundle['scalars'] (CastMap view), so hrr_bpm reaches the series block below.
- await _attachWorkoutSuggestionsAndHrr(bundle, scMap, daySub, day, dataNowSec);
-
- // ── WRIST ORIENTATION during sleep (low-confidence; NOT body position) ───
- _attachWristOrientation(
- bundle, daySub, day.sleepOnsetSec, day.sleepOffsetSec);
-
- // ── ADVANCED SLEEP (4-class Cole–Kripke + DoG/percentile stager) ─────────
- // A richer, AASM-style sleep read (SOL / REM-latency / disturbances + a
- // 4-class hypnogram) computed over the day substrate (needs accel, which
- // lives here, not in the bundle input). ADDITIVE: the canonical single-source
- // `sleep` block (from segmentSleep) is the headline; this is a parallel
- // ESTIMATE detail. Best-effort — never throws.
- bundle['advanced_sleep'] = await _advancedSleep(daySub);
-
- // Feature 6: Restlessness Map (Heatmap of Sleep)
- if (sleepSub.length > 0) {
- final bucketSec = 300; // 5 min
- final moveSum = {};
- final moveCount = {};
- for (var i = 0; i < sleepSub.length; i++) {
- final b = sleepSub.tsSec[i] ~/ bucketSec;
- final ax = sleepSub.ax[i];
- final ay = sleepSub.ay[i];
- final az = sleepSub.az[i];
- final mag = math.sqrt(ax * ax + ay * ay + az * az);
- final enmo = (mag - 1.0).abs();
- moveSum[b] = (moveSum[b] ?? 0.0) + enmo;
- moveCount[b] = (moveCount[b] ?? 0) + 1;
+ // ── SECOND HALF — OFFLOADED to a background isolate ──────────────────────
+ // Everything that turns the isolate-1 bundle into the full day result (wake
+ // features, hybrid steps + TDEE, all-day HRV/RSA/skin-temp Timeline lines,
+ // naps, workout detection + HRR, wrist orientation, restlessness map, fit
+ // quality) used to run on the CALLING isolate — the UI isolate for the
+ // foreground light pass that fires on every sync — hanging the main thread
+ // for seconds (the rolling-RSA Lomb-Scargle over the 24 h day + nap
+ // re-staging + workout detection are the trig/CPU hogs). It is all PURE
+ // compute over the two substrates + a few scalars, so it now runs in
+ // Isolate.run. DB reads that it needs are done HERE (this is the DB-owning
+ // isolate); the DB writes + notification it produces are returned as
+ // descriptors and applied below. Same _perDayTimeout guard as isolate 1.
+ // NON-FATAL: a failure OR timeout anywhere in the offloaded second half must
+ // never skip the whole day. Isolate 1 already computed the headline scalars
+ // (readiness / RHR / RMSSD) into bundle['scalars']; we persist those and just
+ // drop the optional detail blocks. (Previously an exception here threw out of
+ // _derivePreparedDay → the day was marked skipped → readiness rendered "-"
+ // even though it had been computed fine — the "readiness randomly goes -" bug.)
+ // `secondHalfOk` tracks whether this actually completed: a headline-only
+ // row must be marked `partial` below so it never locks as finalized and
+ // never counts as "derived" for the raw-pruning guard (see
+ // LocalDb.dayResultIds) — otherwise a transient failure here permanently
+ // loses the ability to ever back-fill naps/workouts/HRR/wear/curves for
+ // this day once its raw substrate is pruned.
+ var secondHalfOk = true;
+ try {
+ final dayLo = daySub.length == 0 ? 0 : daySub.tsSec.first;
+ final dayHi = daySub.length == 0 ? 0 : daySub.tsSec.last + 60;
+ final coverageWindows =
+ await LocalDb.coverageWindowsOverlapping(dayLo, dayHi);
+ final liveStepsReal = await LocalDb.liveStepsForDay(day.date);
+ final stepCalib = await LocalDb.getStepCalibration();
+ final savedSessions = await LocalDb.sessionsInRange(dayLo, dayHi);
+
+ // Built on THIS isolate so the Isolate.run closure captures only this plain
+ // sendable object (never `this`, `day`, or `bundle`).
+ final blocksInput = _DayBlocksInput(
+ daySub: daySub,
+ sleepSub: sleepSub,
+ profile: profile,
+ onsetSec: day.sleepOnsetSec,
+ offsetSec: day.sleepOffsetSec,
+ rhr: (scMap?['rhr'] as num?)?.toDouble(),
+ maxHrUsed: (bundle['max_hr_used'] as num?)?.round(),
+ coverageWindows: coverageWindows,
+ liveStepsReal: liveStepsReal,
+ stepCalib: stepCalib,
+ savedSessions: savedSessions,
+ date: day.date,
+ dayEndSec: day.endSec,
+ dataNowSec: dataNowSec,
+ );
+ final blocks =
+ await _runDayBlocksCancellable(blocksInput, _perDayTimeout);
+
+ // Merge the computed blocks back into the isolate-1 bundle. scMap is the
+ // CastMap view over bundle['scalars'], so addAll writes through — nap_min /
+ // hrr_bpm reach the persisted series map below.
+ bundle.addAll(blocks.bundlePatch);
+ (bundle['series'] as Map?)?.cast().addAll(
+ blocks.seriesPatch,
+ );
+ scMap?.addAll(blocks.scalarPatch);
+
+ // DB writes + notification the pure compute deferred to us (DB-owning isolate).
+ for (final w in blocks.sessionHrrWrites) {
+ await LocalDb.setSessionHrr(w.$1, w.$2);
}
- final out =