Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion PRIVACY.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Privacy Policy — Edge / OpenStrap

_Last updated: July 20, 2026_
_Last updated: July 27, 2026_

Edge ("the App") is an independent, open-source project. It is not affiliated
with, sponsored by, or endorsed by WHOOP, Inc.
Expand Down Expand Up @@ -43,6 +43,39 @@ 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.

**Location and workout routes**
If you record a run, ride or walk, the App uses your device's location to draw
that workout's route. This is the most sensitive permission the App asks for,
so to be specific about it:

- **Only during a workout.** Location is read only while a run, ride or walk is
actively recording. It stops the moment you finish. The App never reads your
location in the background at any other time.
- **We never ask for "always" access.** The App requests *while-in-use*
location only. Recording does continue while your screen is locked or you
switch apps — otherwise a workout would stop being recorded the moment you
put your phone in your pocket — but that is scoped to the active workout, not
a standing permission to follow you.
- **It is visible while it happens.** On iOS the system's blue location
indicator is shown for the whole time the App is reading location in the
background. On Android the workout runs as a foreground service with a
visible, persistent notification.
- **The App never sends your routes anywhere.** A route is written to a local
database table on your phone and nowhere else. We do not upload it, it is not
included in anonymous diagnostics, and it is not sent to your AI Coach
provider — the coach is technically prevented from reading route data, not
merely asked not to.
- **The one exception is you.** If you tap Share on a workout, the image you
are shown includes a picture of your route, and whatever you send it to
receives it. That is your choice, you see the image before it is sent, and it
goes wherever you send it — not to us.
- **You can delete it.** Deleting a workout deletes its route with it, and
uninstalling the App removes all of it immediately.

You can decline or revoke location access at any time in your device settings.
The App still records the workout — heart rate, duration, strain and the rest —
it simply has no map for it.

**Optional, user-initiated integrations**
If you choose to enable them, the App can also send data to services *you*
configure:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,31 @@ class EdgeTrackingService : Service() {
*/
const val EXTRA_LOCATION = "location"

/**
* Sticky "a GPS route session is live in this process" flag.
*
* WHY THIS EXISTS: several native callers restart the service WITHOUT
* going through Dart — CompanionBridge.onDeviceAppeared (fires whenever
* the band re-enters BLE range, which happens routinely mid-run from
* arm-swing/body-block dropouts), KeepAliveWorker and BootReceiver.
* They use [start] below, whose Intent carries no EXTRA_LOCATION, so
* onStartCommand used to read `false` and re-call startForeground()
* with CONNECTED_DEVICE only — silently STRIPPING the location type off
* a live workout. On Android 14+ that ends location delivery the next
* time the app is backgrounded and the route just stops mid-ride, with
* no crash and no log.
*
* So the extra is now tri-state: present ⇒ authoritative (and latched
* here), absent ⇒ inherit whatever the live session last asked for.
* A process kill resets this to false, which is correct — Dart re-arms
* it via EdgeTracking.start(location: true) when it rehydrates the
* orphaned workout.
*/
@Volatile
@JvmStatic
var locationSessionActive: Boolean = false
private set

/**
* True while the service is alive IN THIS PROCESS. The KeepAliveWorker runs
* in the same process, so this is an exact "is my service running" check —
Expand Down Expand Up @@ -72,12 +97,25 @@ class EdgeTrackingService : Service() {

override fun onDestroy() {
running = false
// The latch is per-process and per-service-lifetime; a fresh service
// must not inherit a stale "route session live" claim.
locationSessionActive = false
super.onDestroy()
}

override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notif = buildNotification()
val withLocation = intent?.getBooleanExtra(EXTRA_LOCATION, false) == true
// Tri-state (see [locationSessionActive]): only an intent that actually
// carries the extra may change the mode. A bare start() from CDM /
// KeepAliveWorker / boot inherits the live session's type instead of
// downgrading it.
val withLocation = if (intent?.hasExtra(EXTRA_LOCATION) == true) {
intent.getBooleanExtra(EXTRA_LOCATION, false).also {
locationSessionActive = it
}
} else {
locationSessionActive
}
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import android.os.Vibrator
import android.os.VibratorManager
import android.provider.Settings
import android.view.KeyEvent
import android.view.WindowManager
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

Expand Down Expand Up @@ -69,6 +70,32 @@ object NativeChannels {
app.stopService(Intent(app, EdgeTrackingService::class.java))
result.success(null)
}
// Hold the screen on for the duration of a live workout, the
// way every run/ride app does — the athlete is glancing at a
// handlebar/armband, not tapping to keep the display awake.
// FLAG_KEEP_SCREEN_ON is scoped to this window and released
// automatically if the activity goes away, so it can never
// leak into a permanent wakelock.
"keepAwake" -> {
val on = call.argument<Boolean>("on") == true
val activity = CompanionBridge.currentActivity
if (activity == null) {
result.success(false)
} else {
activity.runOnUiThread {
if (on) {
activity.window.addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
)
} else {
activity.window.clearFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
)
}
}
result.success(true)
}
}
"consumeHeadlessBootPending" -> {
val prefs = app.getSharedPreferences(
"openstrap_runtime",
Expand Down
38 changes: 37 additions & 1 deletion docs/privacy.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
<main>

<h1>Privacy Policy — Edge / OpenStrap</h1>
<p class="updated">Last updated: July 20, 2026</p>
<p class="updated">Last updated: July 27, 2026</p>

<p>Edge ("the App") is an independent, open-source project. It is not affiliated
with, sponsored by, or endorsed by WHOOP, Inc.</p>
Expand Down Expand Up @@ -66,6 +66,42 @@ <h2>Anonymous diagnostics</h2>
Google's Firebase privacy &amp; security documentation:
<a href="https://firebase.google.com/support/privacy">firebase.google.com/support/privacy</a>.</p>

<h2>Location and workout routes</h2>
<p>If you record a run, ride or walk, the App uses your device's location to
draw that workout's route. This is the most sensitive permission the App
asks for, so to be specific about it:</p>
<ul>
<li><strong>Only during a workout.</strong> Location is read only while a
run, ride or walk is actively recording. It stops the moment you finish.
The App never reads your location in the background at any other time.</li>
<li><strong>We never ask for "always" access.</strong> The App requests
<em>while-in-use</em> location only. Recording does continue while your
screen is locked or you switch apps — otherwise a workout would stop
being recorded the moment you put your phone in your pocket — but that
is scoped to the active workout, not a standing permission to follow
you.</li>
<li><strong>It is visible while it happens.</strong> On iOS the system's
blue location indicator is shown for the whole time the App is reading
location in the background. On Android the workout runs as a foreground
service with a visible, persistent notification.</li>
<li><strong>The App never sends your routes anywhere.</strong> A route is
written to a local database table on your phone and nowhere else. We do
not upload it, it is not included in anonymous diagnostics, and it is
not sent to your AI Coach provider — the coach is technically prevented
from reading route data, not merely asked not to.</li>
<li><strong>The one exception is you.</strong> If you tap Share on a
workout, the image you are shown includes a picture of your route, and
whatever you send it to receives it. That is your choice, you see the
image before it is sent, and it goes wherever you send it — not to
us.</li>
<li><strong>You can delete it.</strong> Deleting a workout deletes its
route with it, and uninstalling the App removes all of it
immediately.</li>
</ul>
<p>You can decline or revoke location access at any time in your device
settings. The App still records the workout — heart rate, duration, strain
and the rest — it simply has no map for it.</p>

<h2>Optional, user-initiated integrations</h2>
<p>If you choose to enable them, the App can also send data to services
<em>you</em> configure:</p>
Expand Down
9 changes: 9 additions & 0 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,15 @@ enum ConfigBridge {
// the paired Apple Watch. Best-effort, never fails the Dart caller.
WatchBridge.shared.pushCurrentState()
result(true)
case "keepAwake":
// Hold the display awake for a live workout, the way every run/ride app
// does. Scoped strictly to the session: Dart clears it on finish, and
// iOS drops it anyway if the app is terminated, so it cannot leak into
// a permanently-awake screen.
let args = call.arguments as? [String: Any] ?? [:]
let on = args["on"] as? Bool ?? false
UIApplication.shared.isIdleTimerDisabled = on
result(true)
default:
result(FlutterMethodNotImplemented)
}
Expand Down
42 changes: 29 additions & 13 deletions ios/Runner/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,19 @@
<key>NSBluetoothPeripheralUsageDescription</key>
<string>OpenStrap connects to your WHOOP band over Bluetooth to sync your health data.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>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.</string>
<!-- NSLocationAlwaysAndWhenInUseUsageDescription: not requested by app code
(gps_source.dart sets allowBackgroundLocationUpdates: false, WhenInUse
only — see the UIBackgroundModes comment below). geolocator_apple's
PermissionHandler.m links [CLLocationManager requestAlwaysAuthorization]
unconditionally, which App Store Connect's static binary scan (ITMS-90683)
flags even though that path is never invoked at runtime. -->
<string>OpenStrap records your route during a run, ride or walk so you can see it, colored by heart-rate zone, when the workout ends. Recording continues while your screen is locked or you switch apps, but only while a workout is running — it stops the moment you finish. Your route stays on this device and is never uploaded.</string>
<!-- NSLocationAlwaysAndWhenInUseUsageDescription: STILL not requested by app
code. gps_source.dart asks for WHEN-IN-USE only; background delivery
during a workout comes from the "location" UIBackgroundMode plus
allowsBackgroundLocationUpdates, which needs no "Always" grant.
geolocator_apple's PermissionHandler.m links
[CLLocationManager requestAlwaysAuthorization] unconditionally, which
App Store Connect's static binary scan (ITMS-90683) flags even though
that path is never invoked at runtime — hence this string exists.
It must still describe the truth, because if it is ever shown, it is
shown to a user deciding about location access. -->
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>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.</string>
<string>OpenStrap never needs always-on location and does not ask for it. It records your route only while a run, ride or walk is actively running — including when your screen is locked — and stops as soon as you finish. Your route stays on this device and is never uploaded.</string>
<!-- NSPhotoLibraryUsageDescription: not used by app code — import_screen.dart's
only FilePicker call uses FileType.any (Files app / UIDocumentPicker),
never .image/.media. file_picker's iOS podspec unconditionally bundles
Expand Down Expand Up @@ -115,11 +119,22 @@
BgSyncScheduler.swift for opportunistic headless sync + heavy derivation,
plus the lighter BGAppRefreshTask (sync-only, no heavy derive) that uses
the separate app-refresh budget iOS grants alongside processing tasks.
NOTE: "location" is deliberately NOT in UIBackgroundModes. The workout
route feature uses WHILE-IN-USE location only (foreground / active-session
with the app alive), so continuous background-location updates aren't
needed and "always"/background-location is a documented follow-up. Adding
the background "location" mode without that need risks App Store rejection.
NOTE: "location" IS in UIBackgroundModes, and is required — not optional.
Without it iOS suspends the process within seconds of a screen lock or an
app switch, which during a run or ride means the fix stream dies, the
route is lost, and a suspended app is the first thing jetsam reclaims
under memory pressure (the user just sees "the app closed mid-ride").
The previous v1 stance — while-in-use only, plus a "keep the screen on"
hint in the UI — could not survive a real 40-minute workout.
Scope is kept tight so this stays honest and App-Store-defensible:
• authorization stays WHEN-IN-USE (we never request "Always");
• background updates are armed ONLY while a route session is live and
disarmed the moment it ends (see lib/gps/gps_source.dart);
• showBackgroundLocationIndicator is ON, so iOS shows the blue pill
the entire time we are reading location in the background;
• routes are written to the on-device workout_route table and are
never uploaded — background mode changes WHEN we can read GPS, not
where any of it goes.
"processing" enables BGProcessingTask; "fetch" enables BGAppRefreshTask. -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
Expand All @@ -129,6 +144,7 @@
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
<string>location</string>
<string>processing</string>
<string>fetch</string>
</array>
Expand Down
2 changes: 1 addition & 1 deletion lib/ai/briefing_engine.dart
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ String partOfDay(DateTime now) {
///
/// THE single source of truth for readiness-score banding — also used by
/// the Today ring's status word (`TodayVitals._orbitHero` in
/// today_screen.dart maps good/moderate/low → Primed/Steady/Run easy).
/// today_screen.dart maps good/moderate/low → Push/Focus/Recover).
/// These cuts (40/66) MUST match the ring's own thresholds: a briefing band
/// computed from different cuts than the ring's word is exactly the
/// tone-vs-score contradiction this function exists to prevent, just moved
Expand Down
Loading
Loading