Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
d4594f4
feat(pairing): add a shared device flow and retain the old band until…
mikenrafter Nov 11, 2026
719408e
merge main
abdulsaheel Nov 13, 2026
ad979d2
replace band: keep the old band's saved name until the replacement co…
abdulsaheel Nov 13, 2026
1f70ba7
merge main
abdulsaheel Nov 13, 2026
aa76467
merge main
abdulsaheel Nov 13, 2026
9751bba
Merge branch 'split/669-reset-help' into split/669-add-device
mikenrafter Nov 13, 2026
07a9837
Keep band replacement isolated and pairing taps reliable
mikenrafter Nov 13, 2026
bbd2c1f
Keep reset guard checks aligned with band replacement
mikenrafter Nov 13, 2026
41963d4
Merge branch 'split/669-reset-help' into split/669-add-device
mikenrafter Nov 13, 2026
2a0f91e
Keep replacement state scoped to the paired band
mikenrafter Nov 13, 2026
4ce7745
merge main
abdulsaheel Nov 13, 2026
a562a18
Reproduce replacement lifetime and pairing outcome regressions
mikenrafter Nov 13, 2026
7eefd83
Keep replacement ownership intact through failure and retirement
mikenrafter Nov 13, 2026
d6fd590
Recheck replacement cancellation after alarm ownership persistence
mikenrafter Nov 13, 2026
2eb8fae
Keep band alarm receipts out of backups and supply pairing test state
mikenrafter Nov 13, 2026
09a572f
Preserve orphan step checkpoints when no live runs are finalized
mikenrafter Nov 13, 2026
796786d
Correct experimental Pebble sync descriptions in every locale
mikenrafter Nov 13, 2026
db817bd
pairing tests: schedule weekday is 0 to 6, so tomorrow on a saturday …
abdulsaheel Nov 14, 2026
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
17 changes: 17 additions & 0 deletions ios/Runner/AccessorySetup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ enum AccessorySetup {
message: "AccessorySetupKit requires iOS 18", details: nil))
}

case "removeBand":
if #available(iOS 18.0, *), let id = call.arguments as? String {
Impl.shared.removeBand(id: id) { result(nil) }
} else {
result(nil)
}

case "removeAll":
if #available(iOS 18.0, *) {
Impl.shared.removeAll { result(nil) }
Expand Down Expand Up @@ -468,6 +475,16 @@ private final class Impl {
remove({ a in !(self.service(of: a).map(sensors.contains) ?? false) }, completion)
}

/// Retire exactly the former main band; keep the replacement and all sensors.
func removeBand(id: String, _ completion: @escaping () -> Void) {
let sensors = sensorServices
let wanted = id.uppercased()
remove({ a in
a.bluetoothIdentifier?.uuidString.uppercased() == wanted
&& !(self.service(of: a).map(sensors.contains) ?? false)
}, completion)
}

/// Deprovisions the SENSOR with CoreBluetooth id [id] (called when it is forgotten), so
/// the next `showSensorPicker` opens a sheet instead of handing back the forgotten one.
/// Never removes a band.
Expand Down
9 changes: 9 additions & 0 deletions lib/ble/accessory_setup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ class AccessorySetup {
} catch (_) {}
}

/// Retire only the old band after the replacement is durable. Best-effort;
/// a failed native removal leaves its approval, never clears the new one.
static Future<void> removeBand(String id) async {
if (!Platform.isIOS) return;
try {
await _ch.invokeMethod('removeBand', id.toUpperCase());
} catch (_) {}
}

/// Deprovision every ASK band, sensors kept (called on unpair). Best-effort.
static Future<void> removeAll() async {
if (!Platform.isIOS) return;
Expand Down
1 change: 1 addition & 0 deletions lib/data/backup_extras.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const Map<String, String> kPrefsNotBackedUp = {
'alarm_schedule_cleared': 'records that Cancel-all ran on this phone, so an '
'import cannot bring a cancelled alarm back',
'alarm_epoch': 'arm belongs to the old band; the restored schedule must arm again',
'alarm_remote_id': 'identifies the band that received this phone’s alarm; restored schedules arm again',
'alarm_epoch_confirmed': 'confirmation belongs to an arm on the old band',
'alarm_set_at_ms': 'confirmation replay floor for the old band arm',
'alarm_pending_until': 'pending confirmation window for the old band arm',
Expand Down
27 changes: 19 additions & 8 deletions lib/data/db.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4323,17 +4323,24 @@ class LocalDb {
// phone is the primary device's own pedometer, so it keeps `''` too —
// `source` is what tells wrist from pocket.
String deviceId = kPrimaryDeviceId,
// The generation when the caller's session began, before async reads.
int? resetGeneration,
}) async {
final generation = resetGeneration ?? ResetGate.generation;
final w = sanitizeCoverageWindow(startTs, endTs, steps);
if (w == null) return;
final db = await instance;
await db.insert('live_coverage', {
'start_ts': w.startTs,
'end_ts': w.endTs,
'steps': steps,
'day': day,
'source': source,
'device_id': deviceId,
// The wipe uses this gate too: a check before queuing can still race it.
await _withCommitGate(() async {
_refuseAfterReset(generation);
final db = await instance;
await db.insert('live_coverage', {
'start_ts': w.startTs,
'end_ts': w.endTs,
'steps': steps,
'day': day,
'source': source,
'device_id': deviceId,
});
});
}

Expand Down Expand Up @@ -4502,6 +4509,9 @@ class LocalDb {
/// import reporting success while banking nothing for those days. Band clips
/// against band. Phone coverage needs no clipping at all: it is replaced
/// wholesale per day (see [replacePhoneCoverageForDay]).
@visibleForTesting
static Future<void> Function()? debugAfterCoverageRead;

static Future<List<List<int>>> coverageWindowsOverlapping(
int loSec,
int hiSec, {
Expand All @@ -4513,6 +4523,7 @@ class LocalDb {
where: 'end_ts >= ? AND start_ts < ? AND source = ?',
whereArgs: [loSec, hiSec, source],
);
await debugAfterCoverageRead?.call();
return [
for (final r in rows)
[(r['start_ts'] as num).toInt(), (r['end_ts'] as num).toInt()],
Expand Down
11 changes: 6 additions & 5 deletions lib/import/noop_ingest.dart
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ class NoopIngest {
// buffered and then dropped without ever deriving.
if (last != null && (_secs.isNotEmpty || _rrMs.isNotEmpty)) {
final st = _stepsByDate.remove(last);
if (st != null) steps += await flushStepCoverage(st, last);
if (st != null) steps += await flushStepCoverage(st, last, resetGeneration: _resetGen);
final sub = _buildSubstrate(_secs, _rrTs, _rrMs);
days += await _engine.deriveImportedDays(sub, _profile, {last},
resetGeneration: _resetGen);
Expand All @@ -242,7 +242,7 @@ class NoopIngest {
// dropping it would lose steps the band actually measured.
for (final e in _stepsByDate.entries) {
_stopIfReset();
steps += await flushStepCoverage(e.value, e.key);
steps += await flushStepCoverage(e.value, e.key, resetGeneration: _resetGen);
}
_stepsByDate.clear();
_stopIfReset();
Expand All @@ -254,7 +254,7 @@ class NoopIngest {
// Real steps must be banked BEFORE deriving: the derivation reads them via
// LocalDb.liveStepsForDay / coverageWindowsOverlapping at derive time.
final st = _stepsByDate.remove(date);
if (st != null) steps += await flushStepCoverage(st, date);
if (st != null) steps += await flushStepCoverage(st, date, resetGeneration: _resetGen);
// Build a Substrate from everything buffered (prev + current date) and
// derive ONLY [date]; the prior evening gives a sleep that started before
// midnight its context.
Expand Down Expand Up @@ -469,14 +469,15 @@ class NoopIngest {
///
/// Returns the steps actually banked (0 when the span was fully covered).
static Future<int> flushStepCoverage(
Map<int, int> stepSamples, String date) async {
Map<int, int> stepSamples, String date, {required int resetGeneration}) async {
if (stepSamples.isEmpty) return 0;
final ts = stepSamples.keys.toList()..sort();
final existing =
await LocalDb.coverageWindowsOverlapping(ts.first, ts.last + 1);
var banked = 0;
for (final r in stepRuns(stepSamples, covered: existing)) {
await LocalDb.addLiveCoverage(r.startSec, r.endSec, r.steps, date);
await LocalDb.addLiveCoverage(r.startSec, r.endSec, r.steps, date,
resetGeneration: resetGeneration);
banked += r.steps;
}
return banked;
Expand Down
34 changes: 32 additions & 2 deletions lib/l10n/app_de.arb
Original file line number Diff line number Diff line change
Expand Up @@ -3087,7 +3087,7 @@
"presentationS": "s",
"presentationV": "µV",
"presentationReadsTheRingDirectlyNoAccountNoKeyExchange": "Liest den Ring direkt aus – kein Konto, kein Schlüsselaustausch.",
"presentationPebble2OrPebble2SEOnlyPairsOnly": "Nur Pebble 2 oder Pebble 2 SE. Vorerst nur Kopplung – noch wird nichts gelesen oder gespeichert.",
"presentationPebble2OrPebble2SEOnlyPairsOnly": "Nur Pebble 2 oder Pebble 2 SE. Experimentelle Unterstützung: synchronisiert und speichert Schritte und Schlaf sowie die Herzfrequenz bei der Pebble 2. Die SE hat keinen Herzfrequenzsensor.",
"presentationTheOriginalFossilSkagenHybridSmartwatchNotTheNewer": "Die ursprüngliche Hybrid-Smartwatch von Fossil/Skagen, nicht die neuere Hybrid HR. Koppelt und verbindet sich; noch wird nichts daraus berechnet.",
"presentationABudgetActivityBandPairsAndBanksItsRaw": "Ein günstiges Aktivitätsband. Koppelt und speichert seine Rohdaten, aber noch wird nichts daraus berechnet.",
"presentationPairsAndBanksItsRawDataNothingIsDecoded": "Koppelt und speichert seine Rohdaten – noch wird nichts dekodiert.",
Expand Down Expand Up @@ -3613,6 +3613,33 @@
"pairModeTitle": "In den Kopplungsmodus versetzen",
"pairModeNoSite": "Ein generisches Gerät ohne einzelnen Hersteller, daher gibt es keine Herstelleranleitung.",
"pairOuraExistingKeyGuidance": "Nutzt du schon die Oura-App? Lass den Ring in der App gekoppelt und kopple ihn mit seinem vorhandenen Schlüssel. Überspringe die Schritte zum Zurücksetzen unten. Setze ihn nur zurück, wenn du einen neuen Schlüssel möchtest.",
"pairAddDevice": "Gerät hinzufügen",
"pairKindAll": "Alle",
"pairKindStrap": "Brustgurte & Armsensoren",
"pairKindRing": "Ringe",
"pairKindWatch": "Uhren",
"pairGoBack": "Zurück",
"pairAddDeviceSub": "Ringe, Uhren und Herzfrequenzgurte",
"pairAddDeviceSubtitle": "Bei jedem Gerät steht, wofür es passt: jeden Tag oder nur beim Training.",
"pairBandFoundBody": "Jeden Tag kann es viel mehr als nur beim Training. Was möchtest du?",
"pairBandFoundBodyHasBand": "Du hast schon ein Band. Es kann immer nur eines dein Band sein.",
"pairIdentifiedBody": "Es sah wie ein Herzfrequenzgurt aus, hat sich beim Verbinden aber als {label} gemeldet. Es wurde nichts gespeichert.",
"pairAsMyBand": "Als mein Band koppeln",
"pairSecondaryPaired": "Fürs Training gekoppelt.",
"pairBandNow": "Dein Band jetzt: {name}. Deine Daten bleiben beim Wechsel erhalten.",
"pairReplaceBand": "Dieses Band koppeln, dann {name} trennen",
"pairReplaceConfirmTitle": "{name} nach erfolgreicher Verbindung ersetzen?",
"pairReplaceConfirmBody": "Dein bisheriges Band bleibt bei Fehlern oder Abbruch gekoppelt. Deine Daten bleiben erhalten.",
"pairReplaceConfirmYes": "Ersatz koppeln",
"pairCurrentBandFallback": "dein Band",
"pairWearableLead": "Es kann deine Tage mit Schlaf, Erholung und Herzfrequenz füllen.",
"pairWearableNote": "Füllt Tage ohne dein WHOOP. Experimentell, also schalte es unter Geräte ein.",
"pairStrapLead": "Es liefert Herzfrequenz beim Training. Schlaf, Erholung und tägliche Belastung zeichnet es nicht auf.",
"pairStrapNote": "Für beides muss „Beim Training nutzen“ an sein.",
"pairDeviceNamed": "{name} koppeln",
"pairUseForWorkouts": "Nur fürs Training nutzen",
"pairReplaceUnpairFailed": "Dein Band ließ sich nicht trennen, daher wurde das neue nicht gekoppelt. Versuche es erneut.",
"pairWearableLeadPebble": "Pebble 2 kann deine Tage mit Schlaf, Erholung und Herzfrequenz füllen. Pebble 2 SE zeigt die eigenen Schlaf- und Schrittdaten der Uhr. Sie hat keinen Herzfrequenzsensor, daher kann diese App daraus weder die tägliche Belastung berechnen noch die Erholung schätzen.",
"welcomeCoachKeyReentry": "Dein API-Schlüssel für den Coach bleibt im sicheren Speicher und wurde nicht gesichert. Gib ihn in den KI-Einstellungen erneut ein, um den Coach auf diesem Telefon zu nutzen.",
"welcomeSettingsSkipped": "{n, plural, =1{1 Einstellung in der Sicherung war nicht lesbar und wurde übersprungen} other{{n} Einstellungen in der Sicherung waren nicht lesbar und wurden übersprungen}}",
"@welcomeSettingsSkipped": {
Expand Down Expand Up @@ -3685,5 +3712,8 @@
"stepCalDoneHousehold": "Gespeichert. Dein Smartphone hat {count} der {band} Schritte gezählt, die das Band erkannt hat. Bewegung im Haushalt wird jetzt auf {gain} skaliert.",
"whoopFirmwareWarningTitle": "WHOOP-Nutzer: Verbinde dich nicht wieder mit der WHOOP-App",
"whoopFirmwareWarningAction": "Wenn du OpenStrap oder eine andere Open-Source-App nutzt oder später zu einer wechseln möchtest, verbinde dein Band nicht wieder mit der offiziellen WHOOP-App. Sie kann Firmware-Updates automatisch installieren. Deinstalliere oder deaktiviere die WHOOP-App, damit sie dein Band nicht aktualisiert.",
"whoopFirmwareWarningDetails": "Berichte zur WHOOP 5.0 / MG Firmware 50.43.1.0 und 50.43.2.0 beschreiben einen blockierten Datenzugriff. Bluetooth kann sich weiterhin verbinden. OpenStrap kann das von WHOOP signierte Mitgliedschafts-Token nicht ausstellen, das betroffene Bänder entsperrt. Wir arbeiten an einer Lösung, aber es gibt noch keine Abhilfe."
"whoopFirmwareWarningDetails": "Berichte zur WHOOP 5.0 / MG Firmware 50.43.1.0 und 50.43.2.0 beschreiben einen blockierten Datenzugriff. Bluetooth kann sich weiterhin verbinden. OpenStrap kann das von WHOOP signierte Mitgliedschafts-Token nicht ausstellen, das betroffene Bänder entsperrt. Wir arbeiten an einer Lösung, aber es gibt noch keine Abhilfe.",
"pairSearchDevices": "Nach Geräten suchen",
"pairPrimaryCannotBeSensor": "Dies ist bereits dein Hauptarmband. Es kann nicht zusätzlich als Trainingssensor gekoppelt werden.",
"pairReplaceMyBand": "Mein Band ersetzen"
}
140 changes: 138 additions & 2 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -14683,7 +14683,7 @@
"presentationS": "s",
"presentationV": "µV",
"presentationReadsTheRingDirectlyNoAccountNoKeyExchange": "Reads the ring directly — no account, no key exchange.",
"presentationPebble2OrPebble2SEOnlyPairsOnly": "Pebble 2 or Pebble 2 SE only. Pairs only for now — nothing is read or stored yet.",
"presentationPebble2OrPebble2SEOnlyPairsOnly": "Pebble 2 or Pebble 2 SE only. Experimental support: syncs and stores steps and sleep, plus heart rate on Pebble 2. The SE has no heart-rate sensor.",
"presentationTheOriginalFossilSkagenHybridSmartwatchNotTheNewer": "The original Fossil/Skagen hybrid smartwatch, not the newer Hybrid HR. Pairs and connects; nothing derives from it yet.",
"presentationABudgetActivityBandPairsAndBanksItsRaw": "A budget activity band. Pairs and banks its raw data, but nothing is derived from it yet.",
"presentationPairsAndBanksItsRawDataNothingIsDecoded": "Pairs and banks its raw data — nothing is decoded yet.",
Expand Down Expand Up @@ -16765,6 +16765,139 @@
"@pairOuraExistingKeyGuidance": {
"description": "Before any Oura reset steps: existing-key users keep the ring paired in Oura and skip resets; reset only for a new key."
},
"pairAddDevice": "Add a device",
"@pairAddDevice": {
"description": "Label of the button that starts adding a device (band or sensor is decided after the scan)."
},
"pairKindAll": "All",
"@pairKindAll": {
"description": "Filter pill that shows every device in the list."
},
"pairKindStrap": "Chest straps & armbands",
"@pairKindStrap": {
"description": "Filter pill: heart-rate straps and armbands."
},
"pairKindRing": "Rings",
"@pairKindRing": {
"description": "Filter pill for smart rings."
},
"pairKindWatch": "Watches",
"@pairKindWatch": {
"description": "Filter pill for watches."
},
"pairGoBack": "Go back",
"@pairGoBack": {
"description": "Link that closes a confirm sheet without pairing."
},
"pairAddDeviceSub": "Rings, watches and heart-rate straps",
"@pairAddDeviceSub": {
"description": "Line under the Add a device row in Settings."
},
"pairAddDeviceSubtitle": "Each device shows where it fits: every day, or workouts only.",
"@pairAddDeviceSubtitle": {
"description": "Lead of the Add a device screen."
},
"pairBandFoundBody": "It does much more every day than during workouts only. Which do you want?",
"@pairBandFoundBody": {
"description": "WHOOP sheet lead when no band is paired yet."
},
"pairBandFoundBodyHasBand": "You already have a band. Only one can be your band at a time.",
"@pairBandFoundBodyHasBand": {
"description": "WHOOP sheet lead when a band is already paired."
},
"pairIdentifiedBody": "It looked like a heart-rate strap, but when we connected it identified itself as {label}. Nothing was saved.",
"@pairIdentifiedBody": {
"description": "Body of the sheet shown when a device picked as a sensor identifies itself as a band after connecting. Nothing was saved.",
"placeholders": {
"label": {
"type": "String"
}
}
},
"pairAsMyBand": "Pair it as my band",
"@pairAsMyBand": {
"description": "Button that pairs the device as the user’s main band."
},
"pairSecondaryPaired": "Paired for workouts only.",
"@pairSecondaryPaired": {
"description": "Shown after a device is paired for workouts only."
},
"pairBandNow": "Your band now: {name}. Your data stays when you switch.",
"@pairBandNow": {
"description": "Note under the table when a band is already paired. {name} is that band’s name.",
"placeholders": {
"name": {
"type": "String"
}
}
},
"pairReplaceBand": "Pair this band, then disconnect {name}",
"@pairReplaceBand": {
"description": "Main button when a band is already paired: unpair it and pair the tapped one. {name} is the paired band’s name.",
"placeholders": {
"name": {
"type": "String"
}
}
},
"pairReplaceConfirmTitle": "Replace {name} after this band connects?",
"@pairReplaceConfirmTitle": {
"description": "Confirm dialog before replacing the band. {name} is the paired band’s name.",
"placeholders": {
"name": {
"type": "String"
}
}
},
"pairReplaceConfirmBody": "Your current band stays paired if setup fails or you cancel. Your data stays.",
"@pairReplaceConfirmBody": {
"description": "Confirm dialog body: replacing the band keeps the data."
},
"pairReplaceConfirmYes": "Pair replacement",
"@pairReplaceConfirmYes": {
"description": "Confirm dialog button: unpair the old band and pair the new one."
},
"pairCurrentBandFallback": "your band",
"@pairCurrentBandFallback": {
"description": "Stands in for the paired band’s name when it has none, inside a sentence."
},
"pairWearableLead": "It can fill your days with sleep, recovery and heart rate.",
"@pairWearableLead": {
"description": "Sheet lead for a ring or watch."
},
"pairWearableNote": "Fills days without your WHOOP. Experimental, so switch it on in Devices.",
"@pairWearableNote": {
"description": "Note under a ring’s or watch’s table on the pairing sheet. “Devices” is the device list in Settings."
},
"pairStrapLead": "It adds heart rate while you work out. It does not record sleep, recovery or daily strain.",
"@pairStrapLead": {
"description": "Sheet lead for a heart-rate strap or sensor."
},
"pairStrapNote": "Both need “Use during workouts” on.",
"@pairStrapNote": {
"description": "Note under a strap’s table: both workout rows need the switch. Quotes deviceUseWorkoutsTitle."
},
"pairDeviceNamed": "Pair {name}",
"@pairDeviceNamed": {
"description": "Main button on a ring’s, watch’s or strap’s sheet. {name} is the device.",
"placeholders": {
"name": {
"type": "String"
}
}
},
"pairUseForWorkouts": "Use it for workouts only",
"@pairUseForWorkouts": {
"description": "Text link on a WHOOP sheet: keep it for workouts only, not as the main band."
},
"pairReplaceUnpairFailed": "Could not unpair your band, so the new one was not paired. Try again.",
"@pairReplaceUnpairFailed": {
"description": "Shown when unpairing the old band failed during “Unpair (your band) and pair this one”; nothing else ran."
},
"pairWearableLeadPebble": "Pebble 2 can fill your days with sleep, recovery and heart rate. Pebble 2 SE shows the watch’s own sleep and steps. It has no heart-rate sensor, so this app cannot calculate daily strain or estimate recovery from it.",
"@pairWearableLeadPebble": {
"description": "Pebble-family pairing lead: SE shows the watch’s own sleep and steps; no HR sensor means no app-calculated daily strain or recovery estimate from the SE alone."
},
"welcomeCoachKeyReentry": "Your coach API key stays in secure storage and was not backed up. Enter it again in AI settings to use the coach on this phone.",
"welcomeSettingsSkipped": "{n, plural, =1{1 setting in the backup could not be read and was skipped} other{{n} settings in the backup could not be read and were skipped}}",
"@welcomeSettingsSkipped": {
Expand Down Expand Up @@ -16805,5 +16938,8 @@
"@strengthSaveFailed": {"description": "Strength set editor copy."},
"napAutoLogTitle": "Log clear naps automatically",
"napAutoLogSub": "A detected nap with strong evidence is added without asking. You can still edit or remove it",
"napAutoLogSaveFailed": "That setting could not be saved. Try again."
"napAutoLogSaveFailed": "That setting could not be saved. Try again.",
"pairSearchDevices": "Search for devices",
"pairPrimaryCannotBeSensor": "This is already your main band. It cannot also be paired as a workout sensor.",
"pairReplaceMyBand": "Replace my band"
}
Loading
Loading