Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
e2826e7
feat(pairing): offer only supported main bands on first run
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
5d5d966
merge main
abdulsaheel Nov 13, 2026
a700ad3
first run: the whoop description only shows under whoop rows
abdulsaheel Nov 13, 2026
1f70ba7
merge main
abdulsaheel Nov 13, 2026
aa76467
merge main
abdulsaheel Nov 13, 2026
b7cfe7d
merge main
abdulsaheel Nov 13, 2026
a6ac04f
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
547d368
Merge branch 'split/669-add-device' into split/669-first-run
mikenrafter Nov 13, 2026
b7e4595
Merge branch 'split/669-add-device' into split/669-first-run
mikenrafter Nov 13, 2026
bbd2c1f
Keep reset guard checks aligned with band replacement
mikenrafter Nov 13, 2026
a3ba119
Distinguish generation comparisons from default assignments
mikenrafter Nov 13, 2026
41963d4
Merge branch 'split/669-reset-help' into split/669-add-device
mikenrafter Nov 13, 2026
9f95b8d
Merge branch 'split/669-add-device' into split/669-first-run
mikenrafter Nov 13, 2026
2a0f91e
Keep replacement state scoped to the paired band
mikenrafter Nov 13, 2026
3ac5229
Merge branch 'split/669-add-device' into split/669-first-run
mikenrafter Nov 13, 2026
437c438
Exercise the scan race from the sensor picker
mikenrafter Nov 13, 2026
4ce7745
merge main
abdulsaheel Nov 13, 2026
b1e0e13
merge split/669-add-device
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
38dc1e1
Merge branch 'split/669-add-device' into split/669-first-run
mikenrafter Nov 13, 2026
d6fd590
Recheck replacement cancellation after alarm ownership persistence
mikenrafter Nov 13, 2026
1639192
Merge branch 'split/669-add-device' into split/669-first-run
mikenrafter Nov 13, 2026
2eb8fae
Keep band alarm receipts out of backups and supply pairing test state
mikenrafter Nov 13, 2026
c39cc36
Merge branch 'split/669-add-device' into split/669-first-run
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
afb1f98
Merge branch 'split/669-add-device' into split/669-first-run
mikenrafter Nov 13, 2026
db817bd
pairing tests: schedule weekday is 0 to 6, so tomorrow on a saturday …
abdulsaheel Nov 14, 2026
f74044a
merge split/669-add-device
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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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
40 changes: 38 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,39 @@
"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.",
"pairMainBandWhoopOnly": "Hauptband: nur WHOOP 4.0 oder WHOOP 5.0/MG. Andere Geräte lassen sich als zusätzliche Geräte hinzufügen.",
"pairBandTitle": "Band koppeln",
"pairBandSubtitle": "Wähle das, das du trägst. Wir suchen es in der Nähe.",
"pairStrapOnlyLink": "Ich habe nur einen Herzfrequenzgurt",
"pairWhoopRowLine": "Schlaf, Erholung, Belastung, Herzfrequenz und mehr",
"pairSetUpTitle": "Einrichten",
"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 +3718,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"
}
Loading