From a5c2b8ebd21fbc95c2b6e142186fc4201df964d7 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Tue, 3 Nov 2026 20:55:38 +0200 Subject: [PATCH 01/36] Weight trend: include scale weighings alongside the journal's weight --- lib/compute/inputs/measurement_inputs.dart | 63 ++++-- lib/l10n/app_de.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_es.arb | 2 +- lib/ui2/screens/journal_compose.dart | 62 +++++- test/weight_trend_scale_test.dart | 219 +++++++++++++++++++++ 6 files changed, 326 insertions(+), 24 deletions(-) create mode 100644 test/weight_trend_scale_test.dart diff --git a/lib/compute/inputs/measurement_inputs.dart b/lib/compute/inputs/measurement_inputs.dart index a24dcc227..a04cb22eb 100644 --- a/lib/compute/inputs/measurement_inputs.dart +++ b/lib/compute/inputs/measurement_inputs.dart @@ -28,12 +28,46 @@ const double kWeighingBand = 0.10; bool nearWeight(num kg, num? near) => near == null || near <= 0 || (kg - near).abs() <= near * kWeighingBand; -/// The newest weighing any paired health-measurement device with its flag -/// on (rule R6, [wearableEnabled]) banked (kg, epoch ms), or null. With -/// [near] (the profile's weight), only weighings within [kWeighingBand] of -/// it count. -Future<({double kg, int atMs})?> newestWeighing({num? near}) async { - ({double kg, int atMs})? newest; +/// The household-band chain over [ws], in time order: the first weighing +/// seeds it and a later one is kept while within [kWeighingBand] of the +/// previous kept one. A run of 3 consecutive rejected weighings, each near +/// the one before it, is a lasting change (a guest seeded the chain, or a +/// real step in weight), so the whole run is kept and its last member becomes +/// the anchor; a rejected weighing that breaks the run starts a new run of 1. +/// Pure and deterministic: same input, same output, however often it runs. +List<({double kg, int atMs})> weighingChain( + Iterable<({double kg, int atMs})> ws, +) { + final sorted = [...ws] + ..sort((a, b) { + final c = a.atMs.compareTo(b.atMs); + return c != 0 ? c : a.kg.compareTo(b.kg); + }); + final chain = <({double kg, int atMs})>[]; + var run = <({double kg, int atMs})>[]; + for (final w in sorted) { + if (chain.isEmpty || nearWeight(w.kg, chain.last.kg)) { + chain.add(w); + run = []; + continue; + } + run = run.isNotEmpty && nearWeight(w.kg, run.last.kg) ? [...run, w] : [w]; + if (run.length >= 3) { + chain.addAll(run); + run = []; + } + } + return chain; +} + +/// Every weighing (kg, epoch ms) of a paired health-measurement device with +/// its flag on (rule R6, [wearableEnabled]), from [fromMs]. With [near] (the +/// profile's weight), only weighings within [kWeighingBand] of it count. +Future> weighings({ + num? near, + int fromMs = 0, +}) async { + final out = <({double kg, int atMs})>[]; for (final row in await LocalDb.deviceRows()) { final adapter = row['adapter_id'] as String?; if (adapter == null || @@ -42,14 +76,21 @@ Future<({double kg, int atMs})?> newestWeighing({num? near}) async { continue; } final w = await LocalDb.deviceObservationValues( - row['id'] as String, 'weight'); + row['id'] as String, 'weight', + fromMs: fromMs); for (final MapEntry(key: at, value: kg) in w.entries) { - if (!nearWeight(kg, near)) continue; - if (newest == null || at > newest.atMs) { - newest = (kg: kg.toDouble(), atMs: at); - } + if (nearWeight(kg, near)) out.add((kg: kg.toDouble(), atMs: at)); } } + return out; +} + +/// The newest of [weighings], or null. +Future<({double kg, int atMs})?> newestWeighing({num? near}) async { + ({double kg, int atMs})? newest; + for (final w in await weighings(near: near)) { + if (newest == null || w.atMs > newest.atMs) newest = w; + } return newest; } diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index b6c5cd7ab..6567c8509 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -811,7 +811,7 @@ "journalComposeNotEnoughEntriesTitle": "Nicht genug Einträge für einen Verlauf", "journalComposeNotEnoughEntriesBody": "Die Linie ist ein Sieben-Tage-Durchschnitt deiner Einträge und braucht daher mindestens zwei Tage. Lücken dazwischen werden nicht aufgefüllt.", "journalComposeSevenDayTrend": "Sieben-Tage-Trend", - "journalComposeTrendFootnote": "Von dir eingegeben. Tage ohne Eintrag bleiben leer.", + "journalComposeTrendFootnote": "Von dir oder deiner Waage eingegeben. Tage ohne Eintrag bleiben leer.", "journalComposeWeightTrendExplainer": "Von dir oder deiner Waage eingetragen – das Band misst kein Gewicht. Angezeigt wird ein Sieben-Tage-Durchschnitt, denn allein durch Wasser und Essen schwankt die Waage um ein bis zwei Kilo, und die Rohwerte würden das wie eine echte Veränderung aussehen lassen. {count, plural, one{{count} Tag eingetragen.} other{{count} Tage eingetragen.}}", "nutritionTabToday": "Heute", "nutritionTabWeek": "Woche", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5a99c0202..ff2fc9266 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3846,7 +3846,7 @@ "@journalComposeSevenDayTrend": { "description": "Title of the weight trend chart." }, - "journalComposeTrendFootnote": "Entered by you. Days with no entry are left empty.", + "journalComposeTrendFootnote": "Entered by you or your scale. Days with no entry are left empty.", "@journalComposeTrendFootnote": { "description": "Footnote under the weight trend chart." }, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 415c2de32..3f603d5e1 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -774,7 +774,7 @@ "journalComposeNotEnoughEntriesTitle": "No hay suficientes registros para una tendencia", "journalComposeNotEnoughEntriesBody": "La línea es un promedio de siete días con lo que introdujiste, así que hacen falta al menos dos días. No se rellena nada entre ellos.", "journalComposeSevenDayTrend": "Tendencia de siete días", - "journalComposeTrendFootnote": "Introducido por ti. Los días sin registro quedan vacíos.", + "journalComposeTrendFootnote": "Introducido por ti o por tu báscula. Los días sin registro quedan vacíos.", "journalComposeWeightTrendExplainer": "Introducido por ti o por tu báscula — la banda no mide el peso. Lo que se dibuja es un promedio de siete días, porque una báscula puede variar uno o dos kilos solo por agua y comida, y las lecturas sin procesar mostrarían eso como algo que le sucede a tu cuerpo. {count, plural, one{Se registró {count} día.} other{Se registraron {count} días.}}", "nutritionTabToday": "Hoy", "nutritionTabWeek": "Semana", diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 0b9ddc44e..82c225878 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -18,6 +18,7 @@ import 'package:provider/provider.dart'; import '../../ai/journal_ai.dart'; import '../../coach/coach_config.dart'; +import '../../compute/inputs/measurement_inputs.dart' show weighingChain, weighings; import '../../data/day_label.dart'; import '../../data/db.dart'; import '../../data/journal_fields.dart'; @@ -917,6 +918,47 @@ class _WeightRow extends StatelessWidget { } } +/// Per-day weight for the trend: the journal's [manual] value and the scale +/// [scaled] weighings (see `weighings`: flag-on, in band), bucketed by LOCAL +/// day, the day's latest weighing standing for it. When a day has both, the +/// later one wins: the journal's time of day against the weighing's, and a +/// journal value with no time loses to the scale. Days with neither are absent. +/// Scale weighings dated after [nowMs] (a band clock set ahead) are dropped. +@visibleForTesting +Map weightByDay( + Map> manual, + List<({double kg, int atMs})> scaled, { + int? nowMs, +}) { + final until = nowMs ?? DateTime.now().millisecondsSinceEpoch; + bool ok(double v) => v.isFinite && v > 0; + final out = {}; + for (final e in manual.entries) { + final v = e.value['weight_kg']; + if (v != null && ok(v.value)) out[e.key] = v.value; + } + // A scale weighs whoever steps on it, and banding against TODAY's profile + // weight would drop a long loss's old readings. So chain instead (see + // weighingChain): off-band weighings are someone else's unless they persist. + final chain = weighingChain( + scaled.where((w) => ok(w.kg) && w.atMs <= until), + ); + final at = {}; + for (final w in chain) { + final t = DateTime.fromMillisecondsSinceEpoch(w.atMs); + final day = dayLabelOf(t); + if (at[day] != null && at[day]! >= w.atMs) continue; + final m = t.hour * 60 + t.minute; + final jm = manual[day]?['weight_kg']?.atMinuteOfDay; + if (out.containsKey(day) && !at.containsKey(day) && jm != null && jm > m) { + continue; + } + at[day] = w.atMs; + out[day] = w.kg; + } + return out; +} + /// The trend, and only the trend. /// /// One line, no headline number, no delta, no arrow, no target and no colour @@ -948,18 +990,18 @@ class _WeightTrendState extends State<_WeightTrend> { DateTime(now.year, now.month, now.day - (kWeightTrendDays - 1)), ); final rows = await LocalDb.journalMetricsByDay(sinceDaysEpoch: since); + // Its own guard: a failing scale read must not blank the manual trend. + var scale = const <({double kg, int atMs})>[]; + try { + scale = await weighings( + fromMs: DateTime.parse(since).millisecondsSinceEpoch, + ); + } catch (e) { + debugPrint('weight trend: scale read failed: $e'); + } if (!mounted) return; setState(() { - _byDay = { - for (final e in rows.entries) - // The `when` does real work — a non-finite or non-positive weight - // is not a weight — and the null-aware element form has nowhere to - // put a guard. - // ignore: use_null_aware_elements - if (e.value['weight_kg']?.value case final v? - when v.isFinite && v > 0) - e.key: v, - }; + _byDay = weightByDay(rows, scale); _loading = false; }); } catch (_) { diff --git a/test/weight_trend_scale_test.dart b/test/weight_trend_scale_test.dart new file mode 100644 index 000000000..8ef9fff24 --- /dev/null +++ b/test/weight_trend_scale_test.dart @@ -0,0 +1,219 @@ +// The weight trend merges scale weighings with the journal's manual weight. +// Pins: one value per LOCAL day, the later of journal/scale wins, a flag-off +// scale and an out-of-band (someone else's) weighing never reach the line. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/inputs/canonical.dart'; +import 'package:openstrap_edge/compute/inputs/measurement_inputs.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/data/journal_fields.dart'; +import 'package:openstrap_edge/data/observation.dart'; +import 'package:openstrap_edge/ui2/screens/journal_compose.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this.root); + final String root; + @override + Future getTemporaryPath() async => root; + @override + Future getApplicationSupportPath() async => root; + @override + Future getApplicationDocumentsPath() async => root; + @override + Future getApplicationCachePath() async => root; + @override + Future getLibraryPath() async => root; + @override + Future getDownloadsPath() async => root; +} + +int _ms(int d, int h, [int m = 0]) => + DateTime(2026, 10, d, h, m).millisecondsSinceEpoch; + +({double kg, int atMs}) _w(double kg, int atMs) => (kg: kg, atMs: atMs); + +Map> _j( + String day, + double kg, [ + int? atMin, +]) => { + day: {'weight_kg': JournalMetricValue(kg, atMinuteOfDay: atMin)}, +}; + +void main() { + group('weightByDay', () { + test('scale-only day', () { + expect(weightByDay({}, [_w(80, _ms(3, 7))]), {'2026-10-03': 80.0}); + }); + + test('manual-only day', () { + expect(weightByDay(_j('2026-10-03', 79.5), []), {'2026-10-03': 79.5}); + }); + + test('no data stays absent', () { + expect(weightByDay({}, []), isEmpty); + }); + + test('latest weighing of the day stands for it', () { + expect(weightByDay({}, [_w(81, _ms(3, 20)), _w(80, _ms(3, 7))]), { + '2026-10-03': 81.0, + }); + }); + + test('both on one day: the later one wins', () { + // Scale at 07:00 (420), journal at 21:00 (1260): journal is later. + expect(weightByDay(_j('2026-10-03', 79, 1260), [_w(80, _ms(3, 7))]), { + '2026-10-03': 79.0, + }); + // Journal at 06:00, scale at 07:00: scale is later. + expect(weightByDay(_j('2026-10-03', 79, 360), [_w(80, _ms(3, 7))]), { + '2026-10-03': 80.0, + }); + // Journal without a time: the scale wins. + expect(weightByDay(_j('2026-10-03', 79), [_w(80, _ms(3, 7))]), { + '2026-10-03': 80.0, + }); + }); + + test('a gradual 95 to 80 kg loss keeps every weighing', () { + final ws = [ + for (var i = 0; i < 16; i++) + _w(95.0 - i, _ms(1, 7) + i * 86400000), + ]; + expect(weightByDay({}, ws).length, 16); + }); + + test('a household outlier amid ~80 kg is dropped', () { + final out = weightByDay({}, [ + _w(80, _ms(3, 7)), + _w(120, _ms(4, 7)), + _w(79.5, _ms(5, 7)), + ]); + expect(out, {'2026-10-03': 80.0, '2026-10-05': 79.5}); + }); + + test('a guest seeding the chain does not lock the user out', () { + final out = weightByDay({}, [ + _w(20, _ms(1, 7)), + _w(70, _ms(2, 7)), + _w(71, _ms(3, 7)), + _w(70, _ms(4, 7)), + ]); + expect(out['2026-10-02'], 70.0); + expect(out['2026-10-03'], 71.0); + expect(out['2026-10-04'], 70.0); + }); + + test('a lasting step in weight re-seeds after three weighings', () { + final out = weightByDay({}, [ + _w(90, _ms(1, 7)), + _w(79, _ms(2, 7)), + _w(79, _ms(3, 7)), + _w(80, _ms(4, 7)), + _w(80, _ms(5, 7)), + ]); + expect(out, { + '2026-10-01': 90.0, + '2026-10-02': 79.0, + '2026-10-03': 79.0, + '2026-10-04': 80.0, + '2026-10-05': 80.0, + }); + }); + + test('a lone outlier or a two-weighing run stays dropped', () { + final lone = weightByDay({}, [ + _w(70, _ms(1, 7)), + _w(120, _ms(2, 7)), + _w(71, _ms(3, 7)), + ]); + expect(lone, {'2026-10-01': 70.0, '2026-10-03': 71.0}); + final two = weightByDay({}, [ + _w(70, _ms(1, 7)), + _w(120, _ms(2, 7)), + _w(121, _ms(3, 7)), + _w(71, _ms(4, 7)), + ]); + expect(two, {'2026-10-01': 70.0, '2026-10-04': 71.0}); + }); + + test('three unrelated outliers do not re-seed', () { + final out = weightByDay({}, [ + _w(70, _ms(1, 7)), + _w(120, _ms(2, 7)), + _w(40, _ms(3, 7)), + _w(120, _ms(4, 7)), + _w(71, _ms(5, 7)), + ]); + expect(out, {'2026-10-01': 70.0, '2026-10-05': 71.0}); + }); + + test('weighings dated after now are dropped', () { + final out = weightByDay( + {}, + [_w(80, _ms(3, 7)), _w(80, _ms(9, 7))], + nowMs: _ms(5, 7), + ); + expect(out, {'2026-10-03': 80.0}); + }); + + test('buckets by local day near midnight', () { + final out = weightByDay({}, [ + _w(80, DateTime(2026, 10, 3, 23, 59).millisecondsSinceEpoch), + _w(81, DateTime(2026, 10, 4, 0, 1).millisecondsSinceEpoch), + ]); + expect(out, {'2026-10-03': 80.0, '2026-10-04': 81.0}); + }); + }); + + group('weighings gate', () { + late Directory tmp; + const scale = 'scale-1'; + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + tmp = await Directory.systemTemp.createTemp('openstrap_weight_trend_'); + PathProviderPlatform.instance = _FakePathProvider(tmp.path); + LocalDb.dbName = 'openstrap_weight_trend_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + await LocalDb.upsertDevice(id: scale, adapterId: 'miscale_bc'); + for (final (d, kg) in [(3, 78.0), (4, 120.0)]) { + await LocalDb.putObservation( + Observation( + sourceKind: ObservationSource.vendor, + key: 'weight', + value: kg, + unit: 'kg', + attribution: 'Mi Scale', + at: DateTime(2026, 10, d, 7), + ), + deviceId: scale, + ); + } + }); + + tearDownAll(() async { + await LocalDb.close(); + if (await tmp.exists()) await tmp.delete(recursive: true); + }); + + test('flag off: excluded; flag on: only in-band weighings', () async { + await LocalDb.setCursor(wearableEnabledCursor('miscale_bc'), '0'); + expect(await weighings(near: 80), isEmpty); + + await LocalDb.setCursor(wearableEnabledCursor('miscale_bc'), '1'); + final w = await weighings(near: 80); // 120 kg is outside the band + expect([for (final x in w) x.kg], [78.0]); + expect(w.single.atMs, _ms(3, 7)); + // No profile weight: nothing to compare against, both count. + expect((await weighings()).length, 2); + }); + }); +} From e8a7afa8845e259f812d04c1a29a703c362f940f Mon Sep 17 00:00:00 2001 From: Bucci <149576997+BucciMobile@users.noreply.github.com> Date: Wed, 4 Nov 2026 08:30:53 +0100 Subject: [PATCH 02/36] .coderabbit.yaml aktualisieren --- .coderabbit.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 770ba8d19..849ea7d60 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,3 +25,5 @@ reviews: # and they are the least likely place for a shipped bug. Re-include them # for a PR that is mostly test work. - "!test/**" + auto_review: + drafts: true \ No newline at end of file From 16a4d628f4a83e609edb3c8c769d97aa19979e14 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:44:38 +0200 Subject: [PATCH 03/36] Weight trend: local-midnight cutoff, fixed test clock, running max for newestWeighing --- lib/compute/inputs/measurement_inputs.dart | 16 +++++---- lib/ui2/screens/journal_compose.dart | 13 ++++--- test/weight_trend_scale_test.dart | 40 +++++++++++++--------- 3 files changed, 43 insertions(+), 26 deletions(-) diff --git a/lib/compute/inputs/measurement_inputs.dart b/lib/compute/inputs/measurement_inputs.dart index a04cb22eb..a9b29565a 100644 --- a/lib/compute/inputs/measurement_inputs.dart +++ b/lib/compute/inputs/measurement_inputs.dart @@ -66,8 +66,12 @@ List<({double kg, int atMs})> weighingChain( Future> weighings({ num? near, int fromMs = 0, -}) async { - final out = <({double kg, int atMs})>[]; +}) => _eachWeighing(near: near, fromMs: fromMs).toList(); + +Stream<({double kg, int atMs})> _eachWeighing({ + num? near, + int fromMs = 0, +}) async* { for (final row in await LocalDb.deviceRows()) { final adapter = row['adapter_id'] as String?; if (adapter == null || @@ -79,16 +83,16 @@ Future> weighings({ row['id'] as String, 'weight', fromMs: fromMs); for (final MapEntry(key: at, value: kg) in w.entries) { - if (nearWeight(kg, near)) out.add((kg: kg.toDouble(), atMs: at)); + if (nearWeight(kg, near)) yield (kg: kg.toDouble(), atMs: at); } } - return out; } -/// The newest of [weighings], or null. +/// The newest of [weighings], or null. A running maximum: the whole history +/// is never held at once. Future<({double kg, int atMs})?> newestWeighing({num? near}) async { ({double kg, int atMs})? newest; - for (final w in await weighings(near: near)) { + await for (final w in _eachWeighing(near: near)) { if (newest == null || w.atMs > newest.atMs) newest = w; } return newest; diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 82c225878..63ee51ebe 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -986,15 +986,20 @@ class _WeightTrendState extends State<_WeightTrend> { // Day arithmetic, not a subtracted duration: a DST day is 23 or 25 hours // long and `now - 180 days` lands on the wrong calendar date across one. final now = DateTime.now(); - final since = dayLabelOf( - DateTime(now.year, now.month, now.day - (kWeightTrendDays - 1)), + final start = DateTime( + now.year, + now.month, + now.day - (kWeightTrendDays - 1), ); + final since = dayLabelOf(start); final rows = await LocalDb.journalMetricsByDay(sinceDaysEpoch: since); // Its own guard: a failing scale read must not blank the manual trend. var scale = const <({double kg, int atMs})>[]; try { scale = await weighings( - fromMs: DateTime.parse(since).millisecondsSinceEpoch, + // Local midnight, matching the local-day buckets below; parsing the + // label would read it as UTC midnight. + fromMs: start.millisecondsSinceEpoch, ); } catch (e) { debugPrint('weight trend: scale read failed: $e'); @@ -1064,7 +1069,7 @@ class _WeightTrendState extends State<_WeightTrend> { yAxis: axis, xLabels: [days.first, days.last], footnote: l?.journalComposeTrendFootnote ?? - 'Entered by you. Days with no entry are left empty.', + 'Entered by you or your scale. Days with no entry are left empty.', series: vals, empty: axis == null ? const NoData() : null, child: axis == null diff --git a/test/weight_trend_scale_test.dart b/test/weight_trend_scale_test.dart index 8ef9fff24..05a18cdb0 100644 --- a/test/weight_trend_scale_test.dart +++ b/test/weight_trend_scale_test.dart @@ -35,6 +35,14 @@ class _FakePathProvider extends PathProviderPlatform { int _ms(int d, int h, [int m = 0]) => DateTime(2026, 10, d, h, m).millisecondsSinceEpoch; +/// `weightByDay` with a fixed clock after every fixture reading, so the +/// future-dated filter never depends on the date the tests run. +Map _byDay( + Map> manual, + List<({double kg, int atMs})> scaled, { + int? nowMs, +}) => weightByDay(manual, scaled, nowMs: nowMs ?? _ms(30, 0)); + ({double kg, int atMs}) _w(double kg, int atMs) => (kg: kg, atMs: atMs); Map> _j( @@ -48,34 +56,34 @@ Map> _j( void main() { group('weightByDay', () { test('scale-only day', () { - expect(weightByDay({}, [_w(80, _ms(3, 7))]), {'2026-10-03': 80.0}); + expect(_byDay({}, [_w(80, _ms(3, 7))]), {'2026-10-03': 80.0}); }); test('manual-only day', () { - expect(weightByDay(_j('2026-10-03', 79.5), []), {'2026-10-03': 79.5}); + expect(_byDay(_j('2026-10-03', 79.5), []), {'2026-10-03': 79.5}); }); test('no data stays absent', () { - expect(weightByDay({}, []), isEmpty); + expect(_byDay({}, []), isEmpty); }); test('latest weighing of the day stands for it', () { - expect(weightByDay({}, [_w(81, _ms(3, 20)), _w(80, _ms(3, 7))]), { + expect(_byDay({}, [_w(81, _ms(3, 20)), _w(80, _ms(3, 7))]), { '2026-10-03': 81.0, }); }); test('both on one day: the later one wins', () { // Scale at 07:00 (420), journal at 21:00 (1260): journal is later. - expect(weightByDay(_j('2026-10-03', 79, 1260), [_w(80, _ms(3, 7))]), { + expect(_byDay(_j('2026-10-03', 79, 1260), [_w(80, _ms(3, 7))]), { '2026-10-03': 79.0, }); // Journal at 06:00, scale at 07:00: scale is later. - expect(weightByDay(_j('2026-10-03', 79, 360), [_w(80, _ms(3, 7))]), { + expect(_byDay(_j('2026-10-03', 79, 360), [_w(80, _ms(3, 7))]), { '2026-10-03': 80.0, }); // Journal without a time: the scale wins. - expect(weightByDay(_j('2026-10-03', 79), [_w(80, _ms(3, 7))]), { + expect(_byDay(_j('2026-10-03', 79), [_w(80, _ms(3, 7))]), { '2026-10-03': 80.0, }); }); @@ -85,11 +93,11 @@ void main() { for (var i = 0; i < 16; i++) _w(95.0 - i, _ms(1, 7) + i * 86400000), ]; - expect(weightByDay({}, ws).length, 16); + expect(_byDay({}, ws).length, 16); }); test('a household outlier amid ~80 kg is dropped', () { - final out = weightByDay({}, [ + final out = _byDay({}, [ _w(80, _ms(3, 7)), _w(120, _ms(4, 7)), _w(79.5, _ms(5, 7)), @@ -98,7 +106,7 @@ void main() { }); test('a guest seeding the chain does not lock the user out', () { - final out = weightByDay({}, [ + final out = _byDay({}, [ _w(20, _ms(1, 7)), _w(70, _ms(2, 7)), _w(71, _ms(3, 7)), @@ -110,7 +118,7 @@ void main() { }); test('a lasting step in weight re-seeds after three weighings', () { - final out = weightByDay({}, [ + final out = _byDay({}, [ _w(90, _ms(1, 7)), _w(79, _ms(2, 7)), _w(79, _ms(3, 7)), @@ -127,13 +135,13 @@ void main() { }); test('a lone outlier or a two-weighing run stays dropped', () { - final lone = weightByDay({}, [ + final lone = _byDay({}, [ _w(70, _ms(1, 7)), _w(120, _ms(2, 7)), _w(71, _ms(3, 7)), ]); expect(lone, {'2026-10-01': 70.0, '2026-10-03': 71.0}); - final two = weightByDay({}, [ + final two = _byDay({}, [ _w(70, _ms(1, 7)), _w(120, _ms(2, 7)), _w(121, _ms(3, 7)), @@ -143,7 +151,7 @@ void main() { }); test('three unrelated outliers do not re-seed', () { - final out = weightByDay({}, [ + final out = _byDay({}, [ _w(70, _ms(1, 7)), _w(120, _ms(2, 7)), _w(40, _ms(3, 7)), @@ -154,7 +162,7 @@ void main() { }); test('weighings dated after now are dropped', () { - final out = weightByDay( + final out = _byDay( {}, [_w(80, _ms(3, 7)), _w(80, _ms(9, 7))], nowMs: _ms(5, 7), @@ -163,7 +171,7 @@ void main() { }); test('buckets by local day near midnight', () { - final out = weightByDay({}, [ + final out = _byDay({}, [ _w(80, DateTime(2026, 10, 3, 23, 59).millisecondsSinceEpoch), _w(81, DateTime(2026, 10, 4, 0, 1).millisecondsSinceEpoch), ]); From 67f1f10f866dec06ff4ae4b26f9d2f2a84d24297 Mon Sep 17 00:00:00 2001 From: David Riedel <48388362+Riedel-David@users.noreply.github.com> Date: Wed, 4 Nov 2026 10:59:11 +0200 Subject: [PATCH 04/36] Weight trend: seed the weighing chain from the full history, plot only the window --- lib/ui2/screens/journal_compose.dart | 17 +++++++++++------ test/weight_trend_scale_test.dart | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lib/ui2/screens/journal_compose.dart b/lib/ui2/screens/journal_compose.dart index 63ee51ebe..0548c3bd3 100644 --- a/lib/ui2/screens/journal_compose.dart +++ b/lib/ui2/screens/journal_compose.dart @@ -924,11 +924,15 @@ class _WeightRow extends StatelessWidget { /// later one wins: the journal's time of day against the weighing's, and a /// journal value with no time loses to the scale. Days with neither are absent. /// Scale weighings dated after [nowMs] (a band clock set ahead) are dropped. +/// The chain runs over every weighing in [scaled]; only those at or after +/// [fromMs] are plotted, so the window's start does not decide which readings +/// anchor the chain (and the same data charts the same on every day). @visibleForTesting Map weightByDay( Map> manual, List<({double kg, int atMs})> scaled, { int? nowMs, + int fromMs = 0, }) { final until = nowMs ?? DateTime.now().millisecondsSinceEpoch; bool ok(double v) => v.isFinite && v > 0; @@ -945,6 +949,7 @@ Map weightByDay( ); final at = {}; for (final w in chain) { + if (w.atMs < fromMs) continue; final t = DateTime.fromMillisecondsSinceEpoch(w.atMs); final day = dayLabelOf(t); if (at[day] != null && at[day]! >= w.atMs) continue; @@ -996,17 +1001,17 @@ class _WeightTrendState extends State<_WeightTrend> { // Its own guard: a failing scale read must not blank the manual trend. var scale = const <({double kg, int atMs})>[]; try { - scale = await weighings( - // Local midnight, matching the local-day buckets below; parsing the - // label would read it as UTC midnight. - fromMs: start.millisecondsSinceEpoch, - ); + // All history, not just the window: the chain's anchor must not + // depend on where the window starts (see weightByDay). + scale = await weighings(); } catch (e) { debugPrint('weight trend: scale read failed: $e'); } if (!mounted) return; setState(() { - _byDay = weightByDay(rows, scale); + // Local midnight, matching the local-day buckets; parsing the label + // would read it as UTC midnight. + _byDay = weightByDay(rows, scale, fromMs: start.millisecondsSinceEpoch); _loading = false; }); } catch (_) { diff --git a/test/weight_trend_scale_test.dart b/test/weight_trend_scale_test.dart index 05a18cdb0..0ed1476be 100644 --- a/test/weight_trend_scale_test.dart +++ b/test/weight_trend_scale_test.dart @@ -41,7 +41,8 @@ Map _byDay( Map> manual, List<({double kg, int atMs})> scaled, { int? nowMs, -}) => weightByDay(manual, scaled, nowMs: nowMs ?? _ms(30, 0)); + int fromMs = 0, +}) => weightByDay(manual, scaled, nowMs: nowMs ?? _ms(30, 0), fromMs: fromMs); ({double kg, int atMs}) _w(double kg, int atMs) => (kg: kg, atMs: atMs); @@ -161,6 +162,21 @@ void main() { expect(out, {'2026-10-01': 70.0, '2026-10-05': 71.0}); }); + test('readings before the window anchor the chain; only the window plots', + () { + final ws = [ + _w(70, _ms(1, 7)), + _w(70, _ms(2, 7)), + _w(30, _ms(5, 7)), + _w(71, _ms(6, 7)), + ]; + // The window opens on the 4th: the 30 kg reading is judged against the + // earlier 70 kg history, not allowed to seed a chain of its own. + expect(_byDay({}, ws, fromMs: _ms(4, 0)), {'2026-10-06': 71.0}); + // Moving the window start must not change a day that is still inside it. + expect(_byDay({}, ws, fromMs: _ms(5, 0)), {'2026-10-06': 71.0}); + }); + test('weighings dated after now are dropped', () { final out = _byDay( {}, From 3644e6952c93d2058768910dfe3f6f0230a11767 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 15:42:25 +0000 Subject: [PATCH 05/36] fix(notify): land detected-activity (nap/workout) notification taps on the Activity tab The /activities/review deep link (emitted for detected naps and workouts) resolved to the Today domain, so tapping a "Did you nap?" notification pushed the review screen over Home instead of over the Activity tab where the suggestion cards live. domainForRoute now maps kRouteDetectedActivities to ShellDomain.activity; tap_router's table keeps the payload's base tab and app.dart decides, as with the other screen routes. Co-authored-by: BucciMobile --- lib/app.dart | 10 +++++++--- lib/notify/tap_router.dart | 5 +++-- test/ui2_router_test.dart | 19 ++++++++++++++++++- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 3e58c40e0..a69717c06 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -437,14 +437,18 @@ ShellDomain domainForRoute(String route) => switch (routePath(route)) { // Water is a journal field logged beside food, and food is a section of // Activity. kRouteWater => ShellDomain.activity, - // The movement/sedentary nudges, recovery-ready and step-goal notes, - // and the detected-activity reviews: all about what is on Today. + // The movement/sedentary nudges, recovery-ready and step-goal notes: + // all about what is on Today. kRouteMovement || kRouteRecovery || kRouteSteps || - kRouteDetectedActivities || kRouteWorkoutSuggestion => ShellDomain.today, + // The detected-activity reviews (naps and workouts alike). The + // suggestion cards they are about live on the Activity tab, so the + // pushed review sits over Activity — the tap lands where the content + // is, not on Home (issue #113's promise, extended to naps). + kRouteDetectedActivities => ShellDomain.activity, // The forgotten-workout nudge. Activity is the destination itself — the // live session bar with its finish control is pinned to the shell there // — so screenForRoute stays null for it. diff --git a/lib/notify/tap_router.dart b/lib/notify/tap_router.dart index 82e9549f7..0dcc05617 100644 --- a/lib/notify/tap_router.dart +++ b/lib/notify/tap_router.dart @@ -134,8 +134,9 @@ const Map _tabRoutes = { }; // Sub-screen routes → the shell tab they sit on top of. Most briefing/journal -// deep links live over Today (0), and so do the detected-workout and -// detected-activity reviews: Home is where they are surfaced and land on close. +// deep links live over Today (0). The detected-activity reviews keep base 0 +// here (the payload's own base) but land on the Activity domain — app.dart's +// `domainForRoute` decides whenever a screen route is present. // // /profile and /recap were BOTH being emitted with neither table knowing them, // so resolveTapRoute fell through to Today and every band-battery alert landed diff --git a/test/ui2_router_test.dart b/test/ui2_router_test.dart index 07b5db88c..08446c76a 100644 --- a/test/ui2_router_test.dart +++ b/test/ui2_router_test.dart @@ -32,6 +32,8 @@ import 'package:openstrap_edge/ui2/onboarding/welcome.dart' show isEncryptedBackup; import 'package:openstrap_edge/ui2/screens/log_workout.dart' show WorkoutSuggestionScreen; +import 'package:openstrap_edge/ui2/screens/detected_activities.dart' + show DetectedActivitiesScreen; import 'package:openstrap_edge/ui2/profile/devices.dart'; import 'package:openstrap_edge/ui2/profile/profile.dart'; import 'package:openstrap_edge/ui2/screens/nutrition_screen.dart' @@ -158,7 +160,7 @@ void main() { kRouteBreathing: ShellDomain.health, kRouteMeds: ShellDomain.health, kRouteWorkoutSuggestion: ShellDomain.today, - kRouteDetectedActivities: ShellDomain.today, + kRouteDetectedActivities: ShellDomain.activity, kRouteWater: ShellDomain.activity, kRouteWorkoutIdle: ShellDomain.activity, kRouteAlarm: ShellDomain.sleep, @@ -196,6 +198,21 @@ void main() { isNull); }); + test('the detected-nap deep link lands on the Activity tab, focused', () { + // "Did you nap?" has to open the review over the Activity tab — where + // the suggestion cards live — not over Home, with the nap's own card + // scrolled into view. + const id = '2026-08-19:nap:1755625800'; + final route = activitySuggestionRoute(id, 'nap'); + expect(domainForRoute(route), ShellDomain.activity); + final screen = screenForRoute(route); + expect(screen, isA()); + expect((screen! as DetectedActivitiesScreen).focusId, id); + // A detected workout rides the same route and the same domain. + expect(domainForRoute(activitySuggestionRoute(id, 'workout')), + ShellDomain.activity); + }); + test('the tab index the shell persists round-trips through the enum', () { for (final d in ShellDomain.values) { expect(ShellDomain.values[d.index], d); From 1b1f613acbe560378c2f7268c4ffd25ad57007cd Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 19:00:40 +0000 Subject: [PATCH 06/36] fix(shell): keep tab state on notification-driven tab switches; fix route doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sourcery review on OpenStrap/edge#668 flagged two issues: - _ShellState drove programmatic tab jumps (notification deep links) by re-keying AppShell (ValueKey(_rev)), which recreated the widget and wiped the IndexedStack — the Activity tab's scroll position and every other visited tab's state were lost when a detected-activity notification was tapped. AppShell now takes an optional ValueNotifier named selection and follows it without being recreated; _go writes the notifier instead of bumping the key. - The kRouteDetectedActivities doc still said the review 'lands on the Workouts tab'; it now says Activity (the old Workouts tab). Adds a regression test: switching tabs via the external notifier preserves a previously visited tab's state. Co-authored-by: BucciMobile --- lib/app.dart | 20 +++++++++--------- lib/notify/tap_router.dart | 9 +++++---- lib/ui2/app_shell.dart | 24 ++++++++++++++++++++++ test/app_shell_back_test.dart | 38 +++++++++++++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 15 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index a69717c06..f82d70a9f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -523,12 +523,10 @@ class _ShellState extends State<_Shell> { late ShellDomain _domain = restoredShellTab(); /// AppShell owns its own selection and takes only an `initial`, so a - /// programmatic jump re-keys it. - // ponytail: re-keying rebuilds the tab stack, so a deep link loses the - // scroll position of whatever tab was open. Deep links are rare and arrive - // from outside the app; give AppShell an external controller if that ever - // stops being true. - int _rev = 0; + /// programmatic jump drives it through this notifier instead of re-keying: + /// recreating AppShell would rebuild the tab stack and lose every visited + /// tab's state (scroll position included) on a notification deep link. + late final ValueNotifier _selection; AppState? _app; @@ -538,6 +536,7 @@ class _ShellState extends State<_Shell> { // A tapped notification asks for a tab via AppState.navRequest. Jump there, // then clear the request so it isn't replayed on rebuild. _app = context.read(); + _selection = ValueNotifier(_domain); _app!.navRequest.addListener(_consumeSoon); _app!.screenRequest.addListener(_consumeSoon); // Cold launch from a tapped notification: the route may already be set @@ -595,11 +594,10 @@ class _ShellState extends State<_Shell> { void _go(ShellDomain d) { if (d == _domain) return; - setState(() { - _domain = d; - _rev++; - }); + setState(() => _domain = d); Prefs.setInt(Prefs.shellTabV2, d.index); + // AppShell follows this without being recreated, so tab state survives. + _selection.value = d; } @override @@ -615,8 +613,8 @@ class _ShellState extends State<_Shell> { // AppState tick. final live = context.select((a) => a.activeWorkout != null); return AppShell( - key: ValueKey(_rev), initial: _domain, + selection: _selection, banner: live ? const _LiveSessionBar() : null, onSelect: (d) { _domain = d; diff --git a/lib/notify/tap_router.dart b/lib/notify/tap_router.dart index 0dcc05617..f25f5fb68 100644 --- a/lib/notify/tap_router.dart +++ b/lib/notify/tap_router.dart @@ -16,10 +16,11 @@ const String kRouteBreathing = '/breathing'; /// that still needs a scroll to find the control is a different promise. const String kRouteWater = '/water'; -/// "Did you work out?" auto-detect notification. Lands on the Workouts tab and -/// pushes a focused review of the detected activity (log or adjust) — the plain -/// `/workouts` route only selected the tab, leaving the suggestion buried in the -/// history list (issue #113). +/// "Did you work out?" / "Did you nap?" auto-detect notification. Lands on the +/// Activity tab (the old Workouts tab) and pushes a focused review of the +/// detected activity (log or adjust) — the plain `/workouts` route only +/// selected the tab, leaving the suggestion buried in the history list +/// (issue #113). /// /// Carries the bout it is about as `?id=` — see /// [workoutSuggestionRoute]. The bare path still resolves (older payloads, and diff --git a/lib/ui2/app_shell.dart b/lib/ui2/app_shell.dart index 3163977ae..43224c5e7 100644 --- a/lib/ui2/app_shell.dart +++ b/lib/ui2/app_shell.dart @@ -71,12 +71,19 @@ class AppShell extends StatefulWidget { /// belongs inside the domain. final Widget? banner; + /// External selection control. When provided, the shell follows changes to + /// it and switches tabs WITHOUT being recreated, so a programmatic jump + /// (a notification deep link) keeps every visited tab's state — scroll + /// position included — alive in the [IndexedStack]. + final ValueNotifier? selection; + const AppShell({ super.key, required this.builder, this.initial = ShellDomain.today, this.onSelect, this.banner, + this.selection, }); @override @@ -93,6 +100,23 @@ class _AppShellState extends State { if (widget.initial != ShellDomain.today) ShellDomain.today, ]; + @override + void initState() { + super.initState(); + widget.selection?.addListener(_followSelection); + } + + @override + void dispose() { + widget.selection?.removeListener(_followSelection); + super.dispose(); + } + + void _followSelection() { + final d = widget.selection!.value; + if (d != _current) _select(d); + } + void _select(ShellDomain d) { setState(() { // Each tab at most once, most recent last, so switching back and forth diff --git a/test/app_shell_back_test.dart b/test/app_shell_back_test.dart index 7071a06c5..90e97d41f 100644 --- a/test/app_shell_back_test.dart +++ b/test/app_shell_back_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ui2/app_shell.dart'; @@ -204,6 +205,43 @@ void main() { await back(tester); expect(find.text('Today count 1'), findsOneWidget); }); + + testWidgets( + 'an external selection switch keeps visited tab state alive', + (tester) async { + // A notification deep link drives AppShell through `selection` rather + // than a re-key: recreating the shell would wipe the IndexedStack state. + final selection = ValueNotifier(ShellDomain.today); + await tester.pumpWidget( + MaterialApp( + theme: buildTheme( + Brightness.light, + ).copyWith(platform: TargetPlatform.android), + home: AppShell( + initial: ShellDomain.today, + selection: selection, + builder: (_, domain) => _TestTab(domain: domain), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('increment Today')); + await tester.pump(); + + selection.value = ShellDomain.activity; + await tester.pumpAndSettle(); + expect(find.text('screen Activity'), findsOneWidget); + await tester.tap(find.text('increment Activity')); + await tester.pump(); + + selection.value = ShellDomain.today; + await tester.pumpAndSettle(); + expect(find.text('Today count 1'), findsOneWidget); + selection.value = ShellDomain.activity; + await tester.pumpAndSettle(); + expect(find.text('Activity count 1'), findsOneWidget); + }, + ); } class _TestTab extends StatefulWidget { From 9226987ff9b0d5bc58511c0a8dedb44212bd98dd Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 19:52:01 +0000 Subject: [PATCH 07/36] fix(shell): sync _selection notifier on manual tab changes and Back onSelect fired for user tab taps and Back navigation updated _domain but not the external selection notifier, so _selection.value could go stale. A later notification _go to the stale value then fired no change event and AppShell did not follow the deep link. Co-authored-by: BucciMobile --- lib/app.dart | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/app.dart b/lib/app.dart index f82d70a9f..9ee600dd4 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -619,6 +619,10 @@ class _ShellState extends State<_Shell> { onSelect: (d) { _domain = d; Prefs.setInt(Prefs.shellTabV2, d.index); + // Manual tab changes (and Back) must update the notifier too: + // otherwise it goes stale and a later _go to that stale value fires + // no change event, so AppShell would not follow the deep link. + _selection.value = d; }, builder: (c, d) => switch (d) { ShellDomain.today => const HomeScreen(), From 6ef6c3f6e7c204eb297bdf4c5bdb31310e621744 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 20:14:04 +0000 Subject: [PATCH 08/36] fix(shell): handle selection notifier swap in didUpdateWidget If AppShell is rebuilt with a different selection notifier, move the _followSelection listener from the old notifier to the new one and reconcile the shell to the new notifier's current value. Co-authored-by: BucciMobile --- lib/ui2/app_shell.dart | 11 +++++++++++ test/app_shell_back_test.dart | 31 +++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/lib/ui2/app_shell.dart b/lib/ui2/app_shell.dart index 43224c5e7..08c5effa6 100644 --- a/lib/ui2/app_shell.dart +++ b/lib/ui2/app_shell.dart @@ -106,6 +106,17 @@ class _AppShellState extends State { widget.selection?.addListener(_followSelection); } + @override + void didUpdateWidget(covariant AppShell oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.selection != oldWidget.selection) { + oldWidget.selection?.removeListener(_followSelection); + widget.selection?.addListener(_followSelection); + // Reconcile with the new notifier's current value. + _followSelection(); + } + } + @override void dispose() { widget.selection?.removeListener(_followSelection); diff --git a/test/app_shell_back_test.dart b/test/app_shell_back_test.dart index 90e97d41f..3f3fda3e2 100644 --- a/test/app_shell_back_test.dart +++ b/test/app_shell_back_test.dart @@ -242,6 +242,37 @@ void main() { expect(find.text('Activity count 1'), findsOneWidget); }, ); + + testWidgets('swapping the selection notifier re-syncs to its current value', ( + tester, + ) async { + Widget shell(ValueNotifier s) => MaterialApp( + theme: buildTheme( + Brightness.light, + ).copyWith(platform: TargetPlatform.android), + home: AppShell( + initial: ShellDomain.today, + selection: s, + builder: (_, domain) => _TestTab(domain: domain), + ), + ); + final first = ValueNotifier(ShellDomain.today); + final second = ValueNotifier(ShellDomain.activity); + await tester.pumpWidget(shell(first)); + await tester.pumpAndSettle(); + expect(find.text('screen Today'), findsOneWidget); + + // Same element position, new notifier instance: didUpdateWidget must + // move the listener and reconcile to the new value. + await tester.pumpWidget(shell(second)); + await tester.pumpAndSettle(); + expect(find.text('screen Activity'), findsOneWidget); + + // The old notifier must no longer drive the shell. + first.value = ShellDomain.health; + await tester.pumpAndSettle(); + expect(find.text('screen Activity'), findsOneWidget); + }); } class _TestTab extends StatefulWidget { From 56e8176512a6c5c2148ae37ed70f728aea6c3be3 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 20:53:31 +0000 Subject: [PATCH 09/36] fix(shell): null-safe, post-frame reconciliation on notifier swap didUpdateWidget now reconciles a swapped selection notifier without invoking onSelect during the build phase (deferred via addPostFrameCallback, guarded by mounted), and _followSelection tolerates a removed (null) notifier instead of throwing on widget.selection!. Co-authored-by: BucciMobile --- lib/ui2/app_shell.dart | 22 ++++++++++++++++------ test/app_shell_back_test.dart | 13 +++++++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/lib/ui2/app_shell.dart b/lib/ui2/app_shell.dart index 08c5effa6..94c6b0371 100644 --- a/lib/ui2/app_shell.dart +++ b/lib/ui2/app_shell.dart @@ -112,8 +112,17 @@ class _AppShellState extends State { if (widget.selection != oldWidget.selection) { oldWidget.selection?.removeListener(_followSelection); widget.selection?.addListener(_followSelection); - // Reconcile with the new notifier's current value. - _followSelection(); + // Reconcile with the new notifier's current value. didUpdateWidget + // runs during the build phase, so the onSelect callback is deferred + // to after the frame — it may setState on the parent. + final s = widget.selection; + if (s != null && s.value != _current) { + final d = s.value; + _select(d, notify: false); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onSelect?.call(d); + }); + } } } @@ -124,11 +133,12 @@ class _AppShellState extends State { } void _followSelection() { - final d = widget.selection!.value; - if (d != _current) _select(d); + final s = widget.selection; + if (s == null) return; + if (s.value != _current) _select(s.value); } - void _select(ShellDomain d) { + void _select(ShellDomain d, {bool notify = true}) { setState(() { // Each tab at most once, most recent last, so switching back and forth // between two tabs does not add a Back press per switch. Today stays in @@ -147,7 +157,7 @@ class _AppShellState extends State { _current = d; _built.add(d); }); - widget.onSelect?.call(d); + if (notify) widget.onSelect?.call(d); } void _back() { diff --git a/test/app_shell_back_test.dart b/test/app_shell_back_test.dart index 3f3fda3e2..2def1e1bf 100644 --- a/test/app_shell_back_test.dart +++ b/test/app_shell_back_test.dart @@ -246,13 +246,15 @@ void main() { testWidgets('swapping the selection notifier re-syncs to its current value', ( tester, ) async { - Widget shell(ValueNotifier s) => MaterialApp( + final notified = []; + Widget shell(ValueNotifier? s) => MaterialApp( theme: buildTheme( Brightness.light, ).copyWith(platform: TargetPlatform.android), home: AppShell( initial: ShellDomain.today, selection: s, + onSelect: notified.add, builder: (_, domain) => _TestTab(domain: domain), ), ); @@ -263,15 +265,22 @@ void main() { expect(find.text('screen Today'), findsOneWidget); // Same element position, new notifier instance: didUpdateWidget must - // move the listener and reconcile to the new value. + // move the listener and reconcile to the new value, delivering onSelect + // after the frame rather than during the build. await tester.pumpWidget(shell(second)); await tester.pumpAndSettle(); expect(find.text('screen Activity'), findsOneWidget); + expect(notified, [ShellDomain.activity]); // The old notifier must no longer drive the shell. first.value = ShellDomain.health; await tester.pumpAndSettle(); expect(find.text('screen Activity'), findsOneWidget); + + // Removing the notifier entirely must not throw. + await tester.pumpWidget(shell(null)); + await tester.pumpAndSettle(); + expect(find.text('screen Activity'), findsOneWidget); }); } From 7f9be3736723b2a525c68e63d70cbfa2de815aa6 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 21:23:34 +0000 Subject: [PATCH 10/36] fix(shell): discard stale deferred onSelect after notifier swap The post-frame callback scheduled in didUpdateWidget captured the domain at scheduling time; if the new notifier moved on before the callback ran, the stale domain was reported via onSelect, which writes back to the notifier in the real shell and would flip the tab back. The callback now only fires when the shell is still mounted and neither the notifier nor its value changed since scheduling. Co-authored-by: BucciMobile --- lib/ui2/app_shell.dart | 7 ++++++- test/app_shell_back_test.dart | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/ui2/app_shell.dart b/lib/ui2/app_shell.dart index 94c6b0371..6d1c9a58b 100644 --- a/lib/ui2/app_shell.dart +++ b/lib/ui2/app_shell.dart @@ -120,7 +120,12 @@ class _AppShellState extends State { final d = s.value; _select(d, notify: false); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onSelect?.call(d); + // Discard the callback if the shell was unmounted, the notifier + // was swapped, or the selection moved on since scheduling — + // reporting the stale domain would write it back via onSelect. + if (mounted && widget.selection == s && s.value == d) { + widget.onSelect?.call(d); + } }); } } diff --git a/test/app_shell_back_test.dart b/test/app_shell_back_test.dart index 2def1e1bf..846bba32c 100644 --- a/test/app_shell_back_test.dart +++ b/test/app_shell_back_test.dart @@ -282,6 +282,39 @@ void main() { await tester.pumpAndSettle(); expect(find.text('screen Activity'), findsOneWidget); }); + + testWidgets('a deferred selection is discarded when the notifier moves on', ( + tester, + ) async { + final notified = []; + Widget shell(ValueNotifier s) => MaterialApp( + theme: buildTheme( + Brightness.light, + ).copyWith(platform: TargetPlatform.android), + home: AppShell( + initial: ShellDomain.today, + selection: s, + onSelect: notified.add, + builder: (_, domain) => _TestTab(domain: domain), + ), + ); + final first = ValueNotifier(ShellDomain.today); + final second = ValueNotifier(ShellDomain.activity); + await tester.pumpWidget(shell(first)); + await tester.pumpAndSettle(); + + // didUpdateWidget schedules the deferred onSelect for Activity; moving + // the notifier to Health before the frame ends must win, and the stale + // Activity callback must be discarded (onSelect writes back to the + // notifier in the real shell, which would flip the tab back). + await tester.pumpWidget(shell(second)); + second.value = ShellDomain.health; + await tester.pump(); + await tester.pumpAndSettle(); + expect(find.text('screen Health'), findsOneWidget); + expect(notified, isNot(contains(ShellDomain.activity))); + expect(notified, contains(ShellDomain.health)); + }); } class _TestTab extends StatefulWidget { From 4c0660360c12f8dfd0b7c3387ed5a21c211f5f0a Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 21:25:45 +0000 Subject: [PATCH 11/36] docs: add WHOOP SpO2 evidence-review proposal (staged, self-critical) Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/proposals/whoop-spo2.md diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md new file mode 100644 index 000000000..d79f895d2 --- /dev/null +++ b/docs/proposals/whoop-spo2.md @@ -0,0 +1,110 @@ +# Proposal: WHOOP SpO2 (blood oxygen) — evidence review and staged plan + +Status: proposal only, no code yet. Written so other contributors can build on +the current state of `protocol` and `edge` without re-doing this research. + +## Process (what was done, openly) + +1. Reviewed `christianmeurer/whoop-reader` (MIT) end to end: 96-byte realtime + packet on the data characteristic, SpO2 claimed at byte 5 (range gate 50–100), + CRC-32 (poly 0x04C11DB7, xor-out 0xF43F44AC) at bytes 92–95. +2. Cross-checked against the community sources it cites + (jogolden/whoomp, bWanShiTong/reverse-engineering-whoop) and against current + open-source projects: ryanbr/noop ecosystem (issues #103, #2814, PRs #73, #11), + LeonardSEO/wearable5.0, rusheelraj.com/blog/whoop, Gadgetbridge issue #5731. +3. Compared with what our own repos already know: `protocol` (`live.dart`, + `records.dart`, `gen5_records.dart`), `whoop-rs` (`whoop-protocol/src/live.rs`, + `spo2_from_paired`, v18 `spo2_pct`), `edge` (`ble_engine.dart`, + `adapters/whoop_gen4.dart`, `adapters/signals.dart`). + +What was NOT done: no real-device captures, no ground-truth comparison against a +reference oximeter, no CRC verification against our own captures. Everything +below that is not marked "verified in our repos" is community knowledge with +the confidence level stated. + +## Findings by generation + +### WHOOP 4.0 — no secured SpO2 + +- whoop-reader decodes byte 5 of the 96-byte realtime packet as SpO2 %. + Single source, no ground truth, only a 50–100 plausibility gate. +- The original reverse-engineering author states blood oxygen on the 4.0 is + "only measured once a day, during sleep" — so byte 5 is expected to carry a + value only inside sleep windows and 0/invalid otherwise. +- Our `protocol` repo verified that `spo2RedRaw`/`spo2IrRaw` (inner 64/66) move + in lockstep with a fixed offset: a red/IR ratio from them tracks channel drift, + not oxygenation (`records.dart` explicitly forbids building SpO2 on them). + noop documents the same fields as "raw ADC; SpO2 % computed server-side". + whoop-rs therefore only computes a ratio-of-ratios estimate on paired v24 + windows and never a calibrated %. +- Open discrepancy: whoop-reader maps the data stream to characteristic + `61080004-…`, while whoop-rs maps `GEN4_DATA` to `61080005-…` and `…0004` to + EVENTS (`whoop-client/src/uuids.rs`). One mapping is off by one; must be + resolved against our own captures before any wiring. +- Constraint from edge AGENTS.md invariant 14: live high-rate streams are + RAM-only, never persisted. A live SpO2 could therefore only ever be an + ephemeral display value, never a day metric. + +### WHOOP 5.0 / MG — community cross-validated, sleep-only + +- v18 historical records carry the strap's own computed SpO2 % at frame-abs + byte 82 (= `inner[74]`), which our `protocol` already exposes as + `spo2CandidateRaw` (`gen5_records.dart`) but deliberately does not publish as + a percentage. +- Measurement schedule, confirmed independently by noop#2814, noop#73 and + wearable5.0 (FINDINGS §13): 30 s measurement bursts every 1,200 s, only while + the band declares sleep. 5/MG never sends red/IR raw channels (noop#11). +- Validation against the vendor app (noop#73): nightly means 93.4–96.7 vs + WHOOP's own 93.1–96.5 over the same weeks (period means 94.4 vs 94.8). No + night had both readings, so this stays an estimate, not a validation. +- Offline derivation of SpO2 from raw PPG is physically not possible on 5.0 + (26 time-multiplexed optical channels; red/IR never sampled simultaneously). + The byte-82 device value is the only usable source. +- Encoding not fully pinned: values above 128 decompose as `128 + flag`, bit 7 + looks like a status flag. This is why `spo2Candidate` is deprecated. + +## Self-critical assessment + +- "Matches the WHOOP app" is agreement, not validation; only a reference + oximeter (or PSG-grade device) validates. We state this explicitly, following + the precedent in `vendor-staged-sleep.md`. +- The gen4 byte-5 claim is single-source and contradicted by the sleep-only + measurement reports; we treat it as unverified. +- No project anywhere publishes a live (daytime) SpO2 for any WHOOP + generation. The realistic product surface is a nightly sleep SpO2 for + 5/MG, plus at most a raw/diagnostic byte for gen4. +- All sources agree the raw ADC channels cannot yield SpO2; any PR deriving % + from red/IR on 4.0 contradicts our own verified findings and should be + rejected. + +## Staged plan (proposed) + +1. **protocol** — raw, gated decode only: + - Keep `spo2CandidateRaw` as-is; add a pinned, documented interpretation + (bit 7 flag, value = low 7 bits) behind a plausibility gate, still + labeled candidate until validated. + - Add a decoder for the 96-byte realtime packet exposing byte 5 as **raw + + CRC flag only** — no percentage publishing. Resolve the characteristic + UUID discrepancy from real captures first. + - Tests from real frames; extend parity fixtures where applicable. +2. **whoop-rs** — mirror the decoders in `whoop-protocol` (live 96-byte packet, + v18 byte 82 interpretation), expose raw values via `whoop-ffi`; keep the + existing `spo2_pct` v18 and `spo2_from_paired` paths unchanged. +3. **edge** — user-facing only the validated-shape path: + - Wire `InputSignal.deviceSpo2` for gen5/5.0/MG as a nightly mean over the + sleep bursts (per-burst median, flag values dropped), labeled as a device + estimate — same labeling discipline as `vendor_staged`. + - Gen4 byte 5 stays diagnostic/raw in logs, never user-facing, until + ground-truth validated. + - Respect invariants: live streams RAM-only (inv. 14), never fabricate a + metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4). +4. **Validation workstream** (prerequisite for any user-facing 4.0 value): + captures against a reference oximeter, byte-82 encoding pin-down, UUID + discrepancy resolution. + +## Out of scope + +- Any medical claim or diagnosis (terms.html: wellness only, not a medical + device). +- Deriving SpO2 from red/IR ADC counts. +- Persisting live-stream data. From 3796d7e49022de329c3afd8f4f891a63fe38b8ee Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Thu, 5 Nov 2026 21:32:04 +0000 Subject: [PATCH 12/36] feat: carry gen5 v18 SpO2 candidate byte as raw diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sample gains nullable spo2CandidateRaw (protocol inner[74]), mapped in sampleFromGen5Historical and logged nonzero in the gen5 history drain. Raw passthrough only — never stored as or displayed as a percentage. Co-authored-by: BucciMobile --- lib/ble/ble_engine.dart | 11 ++++++++ lib/data/models.dart | 13 ++++++++++ test/gen5_spo2_candidate_test.dart | 41 ++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 test/gen5_spo2_candidate_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 3d7f07476..3a5e990e6 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -307,6 +307,10 @@ Sample? sampleFromGen5Historical(Gen5HistoricalRecord? g) { // enum: a stored name freezes a meaning, a stored 2-bit code does not. // Corroboration only, never a stage — see Sample.bandSleepState. bandSleepState: g.sleepStateRawNibble, + // Raw candidate byte, never a percentage — the encoding is not pinned and + // no ground truth exists, so this is carried for diagnostics only and is + // not persisted (see Sample.spo2CandidateRaw). + spo2CandidateRaw: g.spo2CandidateRaw, ); } @@ -5778,6 +5782,13 @@ class BleEngine { // about a vessel: beat-detection quality, motion-artifact rejection, // corroborating the band's own RR intervals. build those, not a number. sample = decodeGen5HistoricalSample(frame.inner); + // Diagnostic only: the gen5 SpO2 candidate byte is raw and unvalidated + // (nonzero only inside band-declared sleep bursts), so it is logged, + // never stored or displayed as a percentage. + final spo2Cand = sample?.spo2CandidateRaw ?? 0; + if (spo2Cand != 0) { + _log('[SPO2] gen5 candidate raw=$spo2Cand sleep=${sample?.bandSleepState} (not a validated %)'); + } } else if (recType != Record.r25 && kKnownRecordVersions.contains(recType)) { // Every gen4 layout version protocol has a field map for — not just diff --git a/lib/data/models.dart b/lib/data/models.dart index 95f7f1a46..558e2cfcb 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -152,6 +152,17 @@ class Sample { /// (analytics `band_offset.dart`) — never to create, extend or stage one. final int? bandSleepState; + /// The gen5 v18 SpO2 candidate byte (protocol `spo2CandidateRaw`, + /// inner[74]) — GEN5/MG ONLY, raw and unvalidated. + /// + /// Zero in ~99% of records, nonzero only during band-declared sleep + /// (community-observed bursts of 30 s every 1,200 s), encoding not fully + /// pinned (values above 128 decompose as 128 + flag). Carried as the raw + /// byte for capture correlation; NEVER displayed or stored as a + /// percentage — see `Spo2Data` in `models/payloads.dart` for the refusal. + /// Not persisted (no column); diagnostic/log use only. + final int? spo2CandidateRaw; + Sample({ required this.tsEpoch, required this.counter, @@ -177,6 +188,7 @@ class Sample { this.dynAccelG, this.tsSubsec, this.bandSleepState, + this.spo2CandidateRaw, }); /// Copy with an overridden [tsEpoch] — used by the clock-offset salvage path @@ -208,6 +220,7 @@ class Sample { dynAccelG: dynAccelG, tsSubsec: tsSubsec, bandSleepState: bandSleepState, + spo2CandidateRaw: spo2CandidateRaw, ); bool get hasDecodedOneHz => diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart new file mode 100644 index 000000000..05b2740a8 --- /dev/null +++ b/test/gen5_spo2_candidate_test.dart @@ -0,0 +1,41 @@ +// The gen5 v18 SpO2 candidate byte (protocol `spo2CandidateRaw`, inner[74]) +// must be carried through `decodeGen5HistoricalSample` as a RAW byte — never +// gated, never converted to a percentage. The byte is unvalidated community +// knowledge (nonzero only in band-declared sleep bursts), so the contract +// under test is passthrough + null on gen4-shaped input, not a value claim. + +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_protocol/openstrap_protocol.dart'; + +/// A minimal decodable v18 inner: length 112, version byte 18 at inner[1]. +Uint8List v18Inner({int spo2Candidate = 0}) { + final b = Uint8List(kGen5V18InnerLen); + b[1] = 18; + final v = ByteData.sublistView(b); + v.setUint32(7, 1780916150, Endian.little); // unix + b[73] = 2; // sleepStateByte: sleep + b[74] = spo2Candidate; + return b; +} + +void main() { + test('gen5 v18 spo2CandidateRaw is carried through as the raw byte', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97)); + expect(s, isNotNull); + expect(s!.spo2CandidateRaw, 97); + expect(s.bandSleepState, isNotNull); + }); + + test('zero candidate stays zero — absent measurement, not a gated null', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 0)); + expect(s!.spo2CandidateRaw, 0); + }); + + test('flag-style values above 128 are passed through unmasked', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 128 + 97)); + expect(s!.spo2CandidateRaw, 128 + 97); + }); +} From f9f368ddda0367b3440f95ec69feaaf9c99fb2a2 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 00:56:36 +0000 Subject: [PATCH 13/36] =?UTF-8?q?docs:=20address=20review=20=E2=80=94=20'a?= =?UTF-8?q?pp=20agreement'=20wording,=20analytics=20owns=20aggregation,=20?= =?UTF-8?q?whoop-rs=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index d79f895d2..15be1d77f 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -45,7 +45,7 @@ the confidence level stated. RAM-only, never persisted. A live SpO2 could therefore only ever be an ephemeral display value, never a day metric. -### WHOOP 5.0 / MG — community cross-validated, sleep-only +### WHOOP 5.0 / MG — community-reported app agreement, sleep-only - v18 historical records carry the strap's own computed SpO2 % at frame-abs byte 82 (= `inner[74]`), which our `protocol` already exposes as @@ -87,13 +87,15 @@ the confidence level stated. CRC flag only** — no percentage publishing. Resolve the characteristic UUID discrepancy from real captures first. - Tests from real frames; extend parity fixtures where applicable. -2. **whoop-rs** — mirror the decoders in `whoop-protocol` (live 96-byte packet, +2. **whoop-rs** — DEFERRED: current scope is `protocol` + `edge` only. When + picked up: mirror the decoders in `whoop-protocol` (live 96-byte packet, v18 byte 82 interpretation), expose raw values via `whoop-ffi`; keep the existing `spo2_pct` v18 and `spo2_from_paired` paths unchanged. -3. **edge** — user-facing only the validated-shape path: - - Wire `InputSignal.deviceSpo2` for gen5/5.0/MG as a nightly mean over the - sleep bursts (per-burst median, flag values dropped), labeled as a device - estimate — same labeling discipline as `vendor_staged`. +3. **edge** — pass through the band-reported `deviceSpo2` samples for + gen5/5.0/MG; the per-burst medians and the nightly mean are computed in + `OpenStrap/analytics` (a metric, not signal wiring — AGENTS.md assigns + metrics there), labeled as a device estimate, same labeling discipline as + `vendor_staged`. - Gen4 byte 5 stays diagnostic/raw in logs, never user-facing, until ground-truth validated. - Respect invariants: live streams RAM-only (inv. 14), never fabricate a From 29b73bf8fccdcef00c2c4719c16b03304ca33a24 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 00:56:54 +0000 Subject: [PATCH 14/36] fix: include record ts/counter in the gen5 SpO2 candidate diagnostic log Co-authored-by: BucciMobile --- lib/ble/ble_engine.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 3a5e990e6..25814b8b2 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -5787,7 +5787,9 @@ class BleEngine { // never stored or displayed as a percentage. final spo2Cand = sample?.spo2CandidateRaw ?? 0; if (spo2Cand != 0) { - _log('[SPO2] gen5 candidate raw=$spo2Cand sleep=${sample?.bandSleepState} (not a validated %)'); + _log('[SPO2] gen5 candidate raw=$spo2Cand ts=${sample?.tsEpoch} ' + 'counter=${sample?.counter} sleep=${sample?.bandSleepState} ' + '(not a validated %)'); } } else if (recType != Record.r25 && kKnownRecordVersions.contains(recType)) { From feeb71a8926a74928a3ce76349c7cc3903ca80de Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 00:59:46 +0000 Subject: [PATCH 15/36] docs: gate any user-facing SpO2 display on the validation workstream (both generations) Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 15be1d77f..a48f01f74 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -96,13 +96,16 @@ the confidence level stated. `OpenStrap/analytics` (a metric, not signal wiring — AGENTS.md assigns metrics there), labeled as a device estimate, same labeling discipline as `vendor_staged`. - - Gen4 byte 5 stays diagnostic/raw in logs, never user-facing, until + - NO user-facing display on either generation until the validation + workstream lands: gen5 byte 82 stays raw/logged until its encoding + (bit-7 flag) is pinned and correlated against a reference device; gen4 + byte 5 stays diagnostic/raw in logs, never user-facing, until ground-truth validated. - Respect invariants: live streams RAM-only (inv. 14), never fabricate a metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4). -4. **Validation workstream** (prerequisite for any user-facing 4.0 value): - captures against a reference oximeter, byte-82 encoding pin-down, UUID - discrepancy resolution. +4. **Validation workstream** (prerequisite for ANY user-facing SpO2 value, + both generations): captures against a reference oximeter, byte-82 encoding + pin-down, UUID discrepancy resolution. ## Out of scope From fb23c04047e77714b33400ddad0b94aa67eac1ce Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 08:43:13 +0000 Subject: [PATCH 16/36] fix: drop the gen5 SpO2 candidate log line entirely FileLog writes to Android external storage (file_log.dart), so a timestamped candidate/sleep line is CWE-532 sensitive-data exposure. The raw byte stays in memory only (Sample.spo2CandidateRaw). Co-authored-by: BucciMobile --- lib/ble/ble_engine.dart | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 25814b8b2..0d6265c5e 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -5782,15 +5782,10 @@ class BleEngine { // about a vessel: beat-detection quality, motion-artifact rejection, // corroborating the band's own RR intervals. build those, not a number. sample = decodeGen5HistoricalSample(frame.inner); - // Diagnostic only: the gen5 SpO2 candidate byte is raw and unvalidated - // (nonzero only inside band-declared sleep bursts), so it is logged, - // never stored or displayed as a percentage. - final spo2Cand = sample?.spo2CandidateRaw ?? 0; - if (spo2Cand != 0) { - _log('[SPO2] gen5 candidate raw=$spo2Cand ts=${sample?.tsEpoch} ' - 'counter=${sample?.counter} sleep=${sample?.bandSleepState} ' - '(not a validated %)'); - } + // The gen5 SpO2 candidate byte stays IN MEMORY ONLY (Sample.spo2CandidateRaw): + // raw and unvalidated, never stored, never displayed as a percentage, and + // NEVER written to _log — FileLog lives in Android external storage, so a + // timestamped candidate/sleep line there would be readable by other apps. } else if (recType != Record.r25 && kKnownRecordVersions.contains(recType)) { // Every gen4 layout version protocol has a field map for — not just From 7c20915dde77aaa113ff38ba1b7864b9e86eb10b Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 08:43:40 +0000 Subject: [PATCH 17/36] docs: RAM-only diagnostics (CWE-532) + explicit accuracy/bias gate thresholds Display gate requires MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp vs a reference oximeter (>=10 nights / 30 pairs); correlation reported separately, never as accuracy proof. Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index a48f01f74..390741568 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -97,15 +97,21 @@ the confidence level stated. metrics there), labeled as a device estimate, same labeling discipline as `vendor_staged`. - NO user-facing display on either generation until the validation - workstream lands: gen5 byte 82 stays raw/logged until its encoding - (bit-7 flag) is pinned and correlated against a reference device; gen4 - byte 5 stays diagnostic/raw in logs, never user-facing, until - ground-truth validated. + workstream lands: gen5 byte 82 stays raw until its encoding (bit-7 flag) + is pinned and validated against a reference device; gen4 byte 5 stays + diagnostic, never user-facing, until ground-truth validated. + - Diagnostics stay RAM-ONLY, never in `_log`/FileLog: FileLog writes to + Android external storage (`lib/sync/file_log.dart`), so a timestamped + candidate/sleep line there is readable by other apps (CWE-532). - Respect invariants: live streams RAM-only (inv. 14), never fabricate a metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4). 4. **Validation workstream** (prerequisite for ANY user-facing SpO2 value, - both generations): captures against a reference oximeter, byte-82 encoding - pin-down, UUID discrepancy resolution. + both generations): paired measurements against a reference oximeter over + at least 10 nights / 30 paired samples; the display gate opens only when + accuracy criteria are met — MAE <= 2 pp AND |mean bias| <= 1 pp AND + Bland-Altman 95% limits of agreement within +/-4 pp. Correlation + (Pearson/Spearman) is reported separately and is NEVER treated as proof of + accuracy. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. ## Out of scope From e85d816cd825c91ed4ec3cbaa862486b28db84aa Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 09:48:22 +0000 Subject: [PATCH 18/36] =?UTF-8?q?docs:=20qualify=20the=20SpO2=20candidate?= =?UTF-8?q?=20comment=20=E2=80=94=20raw=5Farchive=20may=20hold=20the=20byt?= =?UTF-8?q?e=20in=20undecodable=20frames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- lib/ble/ble_engine.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index 0d6265c5e..a14f61d17 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -5782,10 +5782,12 @@ class BleEngine { // about a vessel: beat-detection quality, motion-artifact rejection, // corroborating the band's own RR intervals. build those, not a number. sample = decodeGen5HistoricalSample(frame.inner); - // The gen5 SpO2 candidate byte stays IN MEMORY ONLY (Sample.spo2CandidateRaw): - // raw and unvalidated, never stored, never displayed as a percentage, and - // NEVER written to _log — FileLog lives in Android external storage, so a - // timestamped candidate/sleep line there would be readable by other apps. + // The gen5 SpO2 candidate byte is never written to _log — FileLog lives in + // Android external storage, so a timestamped candidate/sleep line there + // would be readable by other apps (CWE-532). It is not persisted as a + // decoded column either; only whole UNDECODABLE frames reach raw_archive, + // which can contain the byte (kept for the commit-before-ACK invariant). + // In-memory carrier: Sample.spo2CandidateRaw — raw, never a percentage. } else if (recType != Record.r25 && kKnownRecordVersions.contains(recType)) { // Every gen4 layout version protocol has a field map for — not just From 69feaa9bbd99b2c239fb1ffad3226e64dc8a3bee Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 09:48:50 +0000 Subject: [PATCH 19/36] docs: gate must validate the nightly aggregate, not only burst-level pairs Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 390741568..3ba174268 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -111,7 +111,12 @@ the confidence level stated. accuracy criteria are met — MAE <= 2 pp AND |mean bias| <= 1 pp AND Bland-Altman 95% limits of agreement within +/-4 pp. Correlation (Pearson/Spearman) is reported separately and is NEVER treated as proof of - accuracy. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. + accuracy. Burst-level pairs are NOT sufficient: the gate must also compare + the actual NIGHTLY OUTPUT (our aggregation over the band-declared sleep + window, burst weighting included) against the reference aggregated over the + same night, because burst-level agreement can pass while the nightly + calculation uses the wrong window or weights. Plus: byte-82 encoding + pin-down, UUID discrepancy resolution. ## Out of scope From 6150e492d2892cfef5dbfa3ed487a9daa044abb3 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Fri, 6 Nov 2026 12:09:00 +0000 Subject: [PATCH 20/36] docs: gen4 live display gate uses time-aligned live reference, not the nightly gate Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 3ba174268..50414fcd1 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -115,8 +115,12 @@ the confidence level stated. the actual NIGHTLY OUTPUT (our aggregation over the band-declared sleep window, burst weighting included) against the reference aggregated over the same night, because burst-level agreement can pass while the nightly - calculation uses the wrong window or weights. Plus: byte-82 encoding - pin-down, UUID discrepancy resolution. + calculation uses the wrong window or weights. Gen4 has NO nightly SpO2 + output — its byte 5 is a live-stream byte only — so any Gen4 display gate + must instead use time-aligned LIVE reference measurements (same accuracy + thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the + nightly gate. Plus: byte-82 encoding pin-down, UUID discrepancy + resolution. ## Out of scope From 752671ddeb93cd54e23ffabcdc9072d106ec5669 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 21:58:19 +0000 Subject: [PATCH 21/36] =?UTF-8?q?docs:=20address=20upstream=20review=20?= =?UTF-8?q?=E2=80=94=20saturation-range=20coverage=20gate,=20correct=20PPG?= =?UTF-8?q?=20rationale,=20RAM-only=20nuance,=20encoding=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 50414fcd1..7431f4355 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -42,8 +42,11 @@ the confidence level stated. EVENTS (`whoop-client/src/uuids.rs`). One mapping is off by one; must be resolved against our own captures before any wiring. - Constraint from edge AGENTS.md invariant 14: live high-rate streams are - RAM-only, never persisted. A live SpO2 could therefore only ever be an - ephemeral display value, never a day metric. + RAM-only, never persisted. That rules out persisting the stream, NOT a + day metric: an in-memory aggregate over a validated sleep window can still + produce a nightly value without persisting anything. Whether Gen4 ever gets + a nightly metric is decided by validation + coverage, not by invariant 14 + alone. ### WHOOP 5.0 / MG — community-reported app agreement, sleep-only @@ -57,11 +60,15 @@ the confidence level stated. - Validation against the vendor app (noop#73): nightly means 93.4–96.7 vs WHOOP's own 93.1–96.5 over the same weeks (period means 94.4 vs 94.8). No night had both readings, so this stays an estimate, not a validation. -- Offline derivation of SpO2 from raw PPG is physically not possible on 5.0 - (26 time-multiplexed optical channels; red/IR never sampled simultaneously). - The byte-82 device value is the only usable source. -- Encoding not fully pinned: values above 128 decompose as `128 + flag`, bit 7 - looks like a status flag. This is why `spo2Candidate` is deprecated. +- No usable offline derivation from raw PPG on 5.0: time multiplexing alone + would NOT make SpO2 impossible (pulse oximeters alternate red/IR), but the + decoded Gen5 data has no identified red/IR channel pair and the optical + channel bursts are widely separated in time — so no derivation path is + available from what we can decode. The byte-82 device value is the only + usable source. +- Encoding not fully pinned: values above 128 appear to decompose as + `128 + value` with bit 7 acting as a flag/status bit. This is why + `spo2Candidate` is deprecated. ## Self-critical assessment @@ -115,7 +122,13 @@ the confidence level stated. the actual NIGHTLY OUTPUT (our aggregation over the band-declared sleep window, burst weighting included) against the reference aggregated over the same night, because burst-level agreement can pass while the nightly - calculation uses the wrong window or weights. Gen4 has NO nightly SpO2 + calculation uses the wrong window or weights. COVERAGE REQUIREMENT (else a + constant estimate passes by construction): reference samples must span the + saturation range (e.g. stratified bins across 85–100%) and participants must + be representative (FDA oximeter guidance explicitly calls out skin + pigmentation); accuracy is assessed per range/group, and a degenerate + constant estimate (all reference samples ~97%) must NOT open the gate. + Gen4 has NO nightly SpO2 output — its byte 5 is a live-stream byte only — so any Gen4 display gate must instead use time-aligned LIVE reference measurements (same accuracy thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the From 9a7fb418c4f974beec20141b8f99013c17dae4e9 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 21:58:32 +0000 Subject: [PATCH 22/36] docs: fix line wrap in gen4 gate sentence Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 7431f4355..87959afd5 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -128,9 +128,9 @@ the confidence level stated. be representative (FDA oximeter guidance explicitly calls out skin pigmentation); accuracy is assessed per range/group, and a degenerate constant estimate (all reference samples ~97%) must NOT open the gate. - Gen4 has NO nightly SpO2 - output — its byte 5 is a live-stream byte only — so any Gen4 display gate - must instead use time-aligned LIVE reference measurements (same accuracy + Gen4 has NO nightly SpO2 output — its byte 5 is a live-stream byte only — + so any Gen4 display gate must instead use time-aligned LIVE reference + measurements (same accuracy thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the nightly gate. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. From f5bbfbb8dc064470ae117b10e849b2385237106e Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sat, 7 Nov 2026 21:58:51 +0000 Subject: [PATCH 23/36] test: pin spo2CandidateRaw preservation through Sample.copyWith (nonzero + null) Co-authored-by: BucciMobile --- test/gen5_spo2_candidate_test.dart | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart index 05b2740a8..f84f476e7 100644 --- a/test/gen5_spo2_candidate_test.dart +++ b/test/gen5_spo2_candidate_test.dart @@ -38,4 +38,18 @@ void main() { final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 128 + 97)); expect(s!.spo2CandidateRaw, 128 + 97); }); + + // copyWith must carry the candidate along — a regression that drops + // spo2CandidateRaw from copyWith would silently lose it on every copy. + test('copyWith preserves a nonzero spo2CandidateRaw', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; + expect(s.copyWith(heartRate: 60).spo2CandidateRaw, 97); + }); + + test('copyWith preserves an explicitly cleared (null) spo2CandidateRaw', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; + final cleared = s.copyWith(spo2CandidateRaw: null); + expect(cleared.spo2CandidateRaw, isNull); + expect(cleared.copyWith(heartRate: 55).spo2CandidateRaw, isNull); + }); } From 41d79afce6b854cdec75652a4736c3c7e0d4c4fb Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 00:06:08 +0000 Subject: [PATCH 24/36] docs: require per-burst time-aligned reference coverage; validate any future Gen4 nightly output like Gen5 Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 87959afd5..4c983c694 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -128,12 +128,18 @@ the confidence level stated. be representative (FDA oximeter guidance explicitly calls out skin pigmentation); accuracy is assessed per range/group, and a degenerate constant estimate (all reference samples ~97%) must NOT open the gate. - Gen4 has NO nightly SpO2 output — its byte 5 is a live-stream byte only — - so any Gen4 display gate must instead use time-aligned LIVE reference - measurements (same accuracy + BURST COVERAGE: the reference must provide time-aligned readings for EVERY + burst used in the nightly aggregate, and both aggregates must use the SAME + weights — sparse reference samples can match the nightly device output while + missing errors during most of the night. Gen4 has NO nightly SpO2 output — + its byte 5 is a live-stream byte only — so any Gen4 display gate must + instead use time-aligned LIVE reference measurements (same accuracy thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the - nightly gate. Plus: byte-82 encoding pin-down, UUID discrepancy - resolution. + nightly gate. If Gen4 LATER gains a nightly metric (in-memory aggregate over + a validated sleep window), that nightly output must be validated exactly + like the Gen5 one — against the reference aggregated over the same window — + because accurate live samples alone cannot detect wrong window selection or + weighting. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. ## Out of scope From 60754bf61137490ab17f3b92e3e883bf123b83a8 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Sun, 8 Nov 2026 00:21:29 +0000 Subject: [PATCH 25/36] =?UTF-8?q?docs:=20address=20upstream=20review=20rou?= =?UTF-8?q?nd=202=20=E2=80=94=20independent=20window/weights/coverage=20ch?= =?UTF-8?q?ecks,=20FileLog=20rationale,=20gen4=20evidence=20wording,=20ana?= =?UTF-8?q?lytics=20in=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 36 ++++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 4c983c694..3d3756703 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -75,8 +75,10 @@ the confidence level stated. - "Matches the WHOOP app" is agreement, not validation; only a reference oximeter (or PSG-grade device) validates. We state this explicitly, following the precedent in `vendor-staged-sleep.md`. -- The gen4 byte-5 claim is single-source and contradicted by the sleep-only - measurement reports; we treat it as unverified. +- The gen4 byte-5 claim is single-source; the sleep-only measurement reports + do NOT contradict it (a realtime packet can carry SpO2 only during sleep, + which is consistent with the claim). It stays unverified because it has one + source and no ground-truth validation. - No project anywhere publishes a live (daytime) SpO2 for any WHOOP generation. The realistic product surface is a nightly sleep SpO2 for 5/MG, plus at most a raw/diagnostic byte for gen4. @@ -94,8 +96,9 @@ the confidence level stated. CRC flag only** — no percentage publishing. Resolve the characteristic UUID discrepancy from real captures first. - Tests from real frames; extend parity fixtures where applicable. -2. **whoop-rs** — DEFERRED: current scope is `protocol` + `edge` only. When - picked up: mirror the decoders in `whoop-protocol` (live 96-byte packet, +2. **whoop-rs** — DEFERRED. Current scope is `protocol` + `edge` + `analytics` + (analytics owns the nightly metric, see step 3); only whoop-rs is out of + scope for now. When picked up: mirror the decoders in `whoop-protocol` (live 96-byte packet, v18 byte 82 interpretation), expose raw values via `whoop-ffi`; keep the existing `spo2_pct` v18 and `spo2_from_paired` paths unchanged. 3. **edge** — pass through the band-reported `deviceSpo2` samples for @@ -107,9 +110,12 @@ the confidence level stated. workstream lands: gen5 byte 82 stays raw until its encoding (bit-7 flag) is pinned and validated against a reference device; gen4 byte 5 stays diagnostic, never user-facing, until ground-truth validated. - - Diagnostics stay RAM-ONLY, never in `_log`/FileLog: FileLog writes to - Android external storage (`lib/sync/file_log.dart`), so a timestamped - candidate/sleep line there is readable by other apps (CWE-532). + - Diagnostics stay RAM-ONLY, never in `_log`/FileLog. FileLog persists to + Android external storage (`lib/sync/file_log.dart`): on Android ≤10 other + apps with storage permission can read it (CWE-532), and on 11+ the + app-specific dir is no longer cross-app readable — but the data would + still be PERSISTED, which is the primary reason health diagnostics stay + in RAM. - Respect invariants: live streams RAM-only (inv. 14), never fabricate a metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4). 4. **Validation workstream** (prerequisite for ANY user-facing SpO2 value, @@ -129,9 +135,19 @@ the confidence level stated. pigmentation); accuracy is assessed per range/group, and a degenerate constant estimate (all reference samples ~97%) must NOT open the gate. BURST COVERAGE: the reference must provide time-aligned readings for EVERY - burst used in the nightly aggregate, and both aggregates must use the SAME - weights — sparse reference samples can match the nightly device output while - missing errors during most of the night. Gen4 has NO nightly SpO2 output — + burst used in the nightly aggregate — sparse reference samples can match the + nightly device output while missing errors during most of the night — and + MINIMUM SLEEP-WINDOW COVERAGE must be defined with an independent check for + omitted bursts before approving a nightly estimate (data loss leaving only a + few bursts must not pass the gate). WINDOW MEMBERSHIP: included/excluded + bursts are checked independently against the band-declared sleep boundaries, + and the reference aggregate is constructed from the correct window — two + aggregates sharing the same WRONG window can match each other. WEIGHTS: the + expected burst weights are defined independently and the implementation is + checked against them; the reference aggregate must NOT copy the device's + weights (e.g. bursts of 90/90/99 with weights 1/1/8 yield 97.2 instead of + the equal-weight mean 93 — a weighting defect the copied weights would + conceal). Gen4 has NO nightly SpO2 output — its byte 5 is a live-stream byte only — so any Gen4 display gate must instead use time-aligned LIVE reference measurements (same accuracy thresholds: MAE <= 2 pp, |bias| <= 1 pp, 95% LoA within +/-4 pp), never the From f72b4d76b5c3baa0b8da31d9bd9dcf6b0bf7b147 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 9 Nov 2026 15:40:53 +0000 Subject: [PATCH 26/36] =?UTF-8?q?test+docs:=20pin=20the=20#472=20seam=20?= =?UTF-8?q?=E2=80=94=20captured=20candidate=20stays=20unread=20(Spo2Data?= =?UTF-8?q?=20refusal=20intact);=20field=20doc=20points=20to=20Spo2Data=20?= =?UTF-8?q?as=20the=20single=20publication=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- lib/data/models.dart | 5 ++++- test/gen5_spo2_candidate_test.dart | 11 +++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/data/models.dart b/lib/data/models.dart index 558e2cfcb..99fd084c2 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -160,7 +160,10 @@ class Sample { /// pinned (values above 128 decompose as 128 + flag). Carried as the raw /// byte for capture correlation; NEVER displayed or stored as a /// percentage — see `Spo2Data` in `models/payloads.dart` for the refusal. - /// Not persisted (no column); diagnostic/log use only. + /// Not persisted (no column); in-memory diagnostic only, never logged + /// (FileLog is external storage — CWE-532). After the validation gate in + /// docs/proposals/whoop-spo2.md passes, this byte is the raw input that + /// feeds `Spo2Data` — the single publication path. final int? spo2CandidateRaw; Sample({ diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart index f84f476e7..81aaf27e3 100644 --- a/test/gen5_spo2_candidate_test.dart +++ b/test/gen5_spo2_candidate_test.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/models/payloads.dart'; import 'package:openstrap_protocol/openstrap_protocol.dart'; /// A minimal decodable v18 inner: length 112, version byte 18 at inner[1]. @@ -52,4 +53,14 @@ void main() { expect(cleared.spo2CandidateRaw, isNull); expect(cleared.copyWith(heartRate: 55).spo2CandidateRaw, isNull); }); + + // The seam to PR #472: a captured candidate byte must NOT unlock the + // publication path. `Spo2Data` (payloads.dart) stays unread until the + // validation gate passes — capturing is not displaying. + test('captured candidate stays unread: Spo2Data refusal is intact', () { + final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; + expect(s.spo2CandidateRaw, 97); + final payload = TodayData.fromJson({'spo2': const {}}); + expect(payload.spo2, isNull, reason: 'no odi_per_hour -> Spo2Data unread'); + }); } From 4d8a5e1dbbb5bfe6627576b1186722031b3c2f61 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 9 Nov 2026 15:41:08 +0000 Subject: [PATCH 27/36] =?UTF-8?q?docs:=20relate=20to=20merged=20#472=20?= =?UTF-8?q?=E2=80=94=20spo2=20MetricSpec/provenance=20owned=20by=20import?= =?UTF-8?q?=20surface;=20spo2CandidateRaw=20feeds=20Spo2Data=20only=20afte?= =?UTF-8?q?r=20the=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index 3d3756703..c0bf75e9e 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -85,6 +85,13 @@ the confidence level stated. - All sources agree the raw ADC channels cannot yield SpO2; any PR deriving % from red/IR on 4.0 contradicts our own verified findings and should be rejected. +- Relation to merged PR #472: its import surface already owns the `spo2` + MetricSpec (suppressed row, vendor-import provenance, not charted). Live + band-derived SpO2 must NOT create a second `spo2` row or chart key — + provenance (vendor import vs band-derived) stays separate. Our raw capture + (`Sample.spo2CandidateRaw`) is deliberately unread until the gate passes; + the single publication path afterwards is `Spo2Data` in + `lib/models/payloads.dart`. ## Staged plan (proposed) @@ -106,6 +113,9 @@ the confidence level stated. `OpenStrap/analytics` (a metric, not signal wiring — AGENTS.md assigns metrics there), labeled as a device estimate, same labeling discipline as `vendor_staged`. + - After the gate passes: feed `Sample.spo2CandidateRaw` into `Spo2Data` + (`lib/models/payloads.dart`) as the single publication path — no separate + display path, no second `spo2` metric row (see #472 note above). - NO user-facing display on either generation until the validation workstream lands: gen5 byte 82 stays raw until its encoding (bit-7 flag) is pinned and validated against a reference device; gen4 byte 5 stays From 2c755856d18b9de1641fb18d6a25264bf82258c1 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 9 Nov 2026 20:06:54 +0000 Subject: [PATCH 28/36] =?UTF-8?q?docs:=20explicit=20post-gate=20publicatio?= =?UTF-8?q?n=20path=20=E2=80=94=20validation-only=20read,=20Spo2Data=20pct?= =?UTF-8?q?=20field/accessor/getter,=20UI=20via=20existing=20spo2=20series?= =?UTF-8?q?=20with=20provenance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- docs/proposals/whoop-spo2.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index c0bf75e9e..fb33deb52 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -167,6 +167,29 @@ the confidence level stated. because accurate live samples alone cannot detect wrong window selection or weighting. Plus: byte-82 encoding pin-down, UUID discrepancy resolution. +## Publication path after the gate (explicit) + +The gate and the publication path are separate concerns; both are specified +here so the gate is evaluable without shipping a display: + +- **Validation-only read is allowed BEFORE the gate passes.** Evaluating the + gate requires the actual nightly output, so the same nightly aggregation + intended for publication is computed and compared against the reference — + internally only. User-facing output stays withheld until the gate passes; + `Sample.spo2CandidateRaw` remains unread by the UI in the meantime. +- **Payload changes (edge `payloads.dart`):** `Spo2Data` currently has no + percentage accessor and `TodayData.spo2` returns null while `odi_per_hour` + is null. Publication therefore adds: a `spo2Pct` field on `Spo2Data`, a + `double? get spo2Pct` accessor, and a getter condition on `TodayData.spo2` + that accepts the payload once validation has passed (flagged by the + validation record, not by the mere presence of a byte). +- **UI integration:** publishing through `Spo2Data` alone does NOT display + anything — the chart reads `metric_series` and the Explore row stays hidden + without stored `spo2` history. The integration step therefore writes the + validated nightly value into the existing `spo2` series with + band-derived provenance, reusing the #472 import row's provenance axis — + NO second `spo2` row, no second chart key. + ## Out of scope - Any medical claim or diagnosis (terms.html: wellness only, not a medical From 97ea16483c6b6f9c5b174380ca8433c51fa0850b Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Mon, 9 Nov 2026 20:07:09 +0000 Subject: [PATCH 29/36] docs: point to the validation-gate proposal (PR #17 / upstream #675) from the spo2CandidateRaw field doc Co-authored-by: BucciMobile --- lib/data/models.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/data/models.dart b/lib/data/models.dart index 99fd084c2..6b127b382 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -163,7 +163,9 @@ class Sample { /// Not persisted (no column); in-memory diagnostic only, never logged /// (FileLog is external storage — CWE-532). After the validation gate in /// docs/proposals/whoop-spo2.md passes, this byte is the raw input that - /// feeds `Spo2Data` — the single publication path. + /// feeds `Spo2Data` — the single publication path. The validation gate this + /// refers to lives in docs/proposals/whoop-spo2.md, added by PR #17 + /// (upstream OpenStrap/edge#675) — not part of this branch. final int? spo2CandidateRaw; Sample({ From f1c5110f5f28b9cd14fb2d5550a8eb8661e382a3 Mon Sep 17 00:00:00 2001 From: Vibe Nuage Agent Date: Tue, 10 Nov 2026 04:36:09 +0000 Subject: [PATCH 30/36] =?UTF-8?q?fix:=20copyWith=20tests=20use=20the=20rea?= =?UTF-8?q?l=20signature=20(tsEpoch=20only)=20=E2=80=94=20Sample=20has=20n?= =?UTF-8?q?o=20heartRate/spo2CandidateRaw=20override=20params?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: BucciMobile --- test/gen5_spo2_candidate_test.dart | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart index 81aaf27e3..4f7311017 100644 --- a/test/gen5_spo2_candidate_test.dart +++ b/test/gen5_spo2_candidate_test.dart @@ -8,6 +8,7 @@ import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ble/ble_engine.dart'; +import 'package:openstrap_edge/data/models.dart'; import 'package:openstrap_edge/models/payloads.dart'; import 'package:openstrap_protocol/openstrap_protocol.dart'; @@ -42,16 +43,17 @@ void main() { // copyWith must carry the candidate along — a regression that drops // spo2CandidateRaw from copyWith would silently lose it on every copy. + // (copyWith only overrides tsEpoch; the field must pass through unchanged.) test('copyWith preserves a nonzero spo2CandidateRaw', () { final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; - expect(s.copyWith(heartRate: 60).spo2CandidateRaw, 97); + expect(s.copyWith(tsEpoch: s.tsEpoch + 1).spo2CandidateRaw, 97); }); - test('copyWith preserves an explicitly cleared (null) spo2CandidateRaw', () { - final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 97))!; - final cleared = s.copyWith(spo2CandidateRaw: null); - expect(cleared.spo2CandidateRaw, isNull); - expect(cleared.copyWith(heartRate: 55).spo2CandidateRaw, isNull); + test('copyWith preserves an absent (null) spo2CandidateRaw', () { + // A gen4-shaped sample carries no candidate byte at all. + final s = Sample(tsEpoch: 1000, counter: 0, hr: 60); + expect(s.spo2CandidateRaw, isNull); + expect(s.copyWith(tsEpoch: 1001).spo2CandidateRaw, isNull); }); // The seam to PR #472: a captured candidate byte must NOT unlock the From 85ecde18fa05570f0462491d9b8eefda9c57f4e0 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:22:36 +0530 Subject: [PATCH 31/36] l10n: add report contact strings for es, fr, hi, zh (main's l10n test fails without them) --- lib/l10n/app_es.arb | 4 +++- lib/l10n/app_fr.arb | 4 +++- lib/l10n/app_hi.arb | 4 +++- lib/l10n/app_zh.arb | 4 +++- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 415c2de32..62fe88e66 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -2890,5 +2890,7 @@ "type": "int" } } - } + }, + "reportContactLabel": "Contacto (opcional)", + "reportContactHint": "Correo o Discord, solo si quieres una respuesta" } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7c26d7e89..41cab9ff7 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -2861,5 +2861,7 @@ "type": "int" } } - } + }, + "reportContactLabel": "Contact (facultatif)", + "reportContactHint": "E-mail ou Discord, seulement si vous voulez une réponse" } diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 990d67975..6159de0a0 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -2861,5 +2861,7 @@ "type": "int" } } - } + }, + "reportContactLabel": "संपर्क (वैकल्पिक)", + "reportContactHint": "ईमेल या Discord, केवल अगर आप जवाब चाहते हैं" } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1cf2c9848..472e0d493 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -2861,5 +2861,7 @@ "type": "int" } } - } + }, + "reportContactLabel": "联系方式(可选)", + "reportContactHint": "邮箱或 Discord,仅在你希望得到回复时填写" } From 01490f3391be4b52bb67dbff64fad04aaabd926c Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:25:29 +0530 Subject: [PATCH 32/36] nap notif tab: dispose the selection notifier, fix the deferred-selection test timing, drop duplicate import and unrelated coderabbit change --- .coderabbit.yaml | 2 -- lib/app.dart | 1 + test/app_shell_back_test.dart | 8 +++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 849ea7d60..770ba8d19 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,5 +25,3 @@ reviews: # and they are the least likely place for a shipped bug. Re-include them # for a PR that is mostly test work. - "!test/**" - auto_review: - drafts: true \ No newline at end of file diff --git a/lib/app.dart b/lib/app.dart index 9ee600dd4..33bcc2551 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -604,6 +604,7 @@ class _ShellState extends State<_Shell> { void dispose() { _app?.navRequest.removeListener(_consumeSoon); _app?.screenRequest.removeListener(_consumeSoon); + _selection.dispose(); super.dispose(); } diff --git a/test/app_shell_back_test.dart b/test/app_shell_back_test.dart index 846bba32c..801754814 100644 --- a/test/app_shell_back_test.dart +++ b/test/app_shell_back_test.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/ui2/app_shell.dart'; @@ -307,9 +306,12 @@ void main() { // the notifier to Health before the frame ends must win, and the stale // Activity callback must be discarded (onSelect writes back to the // notifier in the real shell, which would flip the tab back). + // pumpWidget runs a whole frame, so the move has to be queued ahead of + // the shell's own post-frame callback to land between schedule and fire. + WidgetsBinding.instance.addPostFrameCallback( + (_) => second.value = ShellDomain.health, + ); await tester.pumpWidget(shell(second)); - second.value = ShellDomain.health; - await tester.pump(); await tester.pumpAndSettle(); expect(find.text('screen Health'), findsOneWidget); expect(notified, isNot(contains(ShellDomain.activity))); From 060cfdeffc950ce2f296dd836cec1ebbdb1235f9 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:33:47 +0530 Subject: [PATCH 33/36] spo2 proposal: no named outside projects, byte 82 stays opaque, wearer minimum on the gate, FileLog claim corrected, drop unrelated coderabbit change --- .coderabbit.yaml | 2 -- docs/proposals/whoop-spo2.md | 60 +++++++++++++++++------------------- 2 files changed, 29 insertions(+), 33 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 849ea7d60..770ba8d19 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,5 +25,3 @@ reviews: # and they are the least likely place for a shipped bug. Re-include them # for a PR that is mostly test work. - "!test/**" - auto_review: - drafts: true \ No newline at end of file diff --git a/docs/proposals/whoop-spo2.md b/docs/proposals/whoop-spo2.md index fb33deb52..3b83659fe 100644 --- a/docs/proposals/whoop-spo2.md +++ b/docs/proposals/whoop-spo2.md @@ -5,13 +5,12 @@ the current state of `protocol` and `edge` without re-doing this research. ## Process (what was done, openly) -1. Reviewed `christianmeurer/whoop-reader` (MIT) end to end: 96-byte realtime - packet on the data characteristic, SpO2 claimed at byte 5 (range gate 50–100), - CRC-32 (poly 0x04C11DB7, xor-out 0xF43F44AC) at bytes 92–95. -2. Cross-checked against the community sources it cites - (jogolden/whoomp, bWanShiTong/reverse-engineering-whoop) and against current - open-source projects: ryanbr/noop ecosystem (issues #103, #2814, PRs #73, #11), - LeonardSEO/wearable5.0, rusheelraj.com/blog/whoop, Gadgetbridge issue #5731. +1. Collected the existing claims about the 96-byte realtime packet on the data + characteristic: SpO2 at byte 5 (range gate 50–100), CRC-32 + (poly 0x04C11DB7, xor-out 0xF43F44AC) at bytes 92–95. Single source, + unverified. +2. Checked which claims more than one independent source agrees on, and which + rest on a single source. 3. Compared with what our own repos already know: `protocol` (`live.dart`, `records.dart`, `gen5_records.dart`), `whoop-rs` (`whoop-protocol/src/live.rs`, `spo2_from_paired`, v18 `spo2_pct`), `edge` (`ble_engine.dart`, @@ -19,25 +18,24 @@ the current state of `protocol` and `edge` without re-doing this research. What was NOT done: no real-device captures, no ground-truth comparison against a reference oximeter, no CRC verification against our own captures. Everything -below that is not marked "verified in our repos" is community knowledge with +below that is not marked "verified in our repos" is an unverified claim with the confidence level stated. ## Findings by generation ### WHOOP 4.0 — no secured SpO2 -- whoop-reader decodes byte 5 of the 96-byte realtime packet as SpO2 %. - Single source, no ground truth, only a 50–100 plausibility gate. -- The original reverse-engineering author states blood oxygen on the 4.0 is - "only measured once a day, during sleep" — so byte 5 is expected to carry a - value only inside sleep windows and 0/invalid otherwise. +- Byte 5 of the 96-byte realtime packet is claimed to be SpO2 %. Single + source, no ground truth, only a 50–100 plausibility gate. +- The same source says blood oxygen on the 4.0 is only measured once a day, + during sleep, so byte 5 is expected to carry a value only inside sleep windows + and 0/invalid otherwise. Unverified. - Our `protocol` repo verified that `spo2RedRaw`/`spo2IrRaw` (inner 64/66) move in lockstep with a fixed offset: a red/IR ratio from them tracks channel drift, not oxygenation (`records.dart` explicitly forbids building SpO2 on them). - noop documents the same fields as "raw ADC; SpO2 % computed server-side". whoop-rs therefore only computes a ratio-of-ratios estimate on paired v24 windows and never a calibrated %. -- Open discrepancy: whoop-reader maps the data stream to characteristic +- Open discrepancy: the single source maps the data stream to characteristic `61080004-…`, while whoop-rs maps `GEN4_DATA` to `61080005-…` and `…0004` to EVENTS (`whoop-client/src/uuids.rs`). One mapping is off by one; must be resolved against our own captures before any wiring. @@ -48,16 +46,16 @@ the confidence level stated. a nightly metric is decided by validation + coverage, not by invariant 14 alone. -### WHOOP 5.0 / MG — community-reported app agreement, sleep-only +### WHOOP 5.0 / MG — sleep-only, agreement with the vendor app unverified - v18 historical records carry the strap's own computed SpO2 % at frame-abs byte 82 (= `inner[74]`), which our `protocol` already exposes as `spo2CandidateRaw` (`gen5_records.dart`) but deliberately does not publish as a percentage. -- Measurement schedule, confirmed independently by noop#2814, noop#73 and - wearable5.0 (FINDINGS §13): 30 s measurement bursts every 1,200 s, only while - the band declares sleep. 5/MG never sends red/IR raw channels (noop#11). -- Validation against the vendor app (noop#73): nightly means 93.4–96.7 vs +- Measurement schedule, reported by more than one independent source: 30 s + measurement bursts every 1,200 s, only while the band declares sleep. 5/MG is + reported to never send red/IR raw channels. Not verified by us. +- Reported comparison with the vendor app: nightly means 93.4–96.7 vs WHOOP's own 93.1–96.5 over the same weeks (period means 94.4 vs 94.8). No night had both readings, so this stays an estimate, not a validation. - No usable offline derivation from raw PPG on 5.0: time multiplexing alone @@ -66,9 +64,8 @@ the confidence level stated. channel bursts are widely separated in time — so no derivation path is available from what we can decode. The byte-82 device value is the only usable source. -- Encoding not fully pinned: values above 128 appear to decompose as - `128 + value` with bit 7 acting as a flag/status bit. This is why - `spo2Candidate` is deprecated. +- Encoding unknown. Treat byte 82 as an opaque byte until a validation run + pins its encoding; this is why `spo2Candidate` is deprecated. ## Self-critical assessment @@ -96,9 +93,9 @@ the confidence level stated. ## Staged plan (proposed) 1. **protocol** — raw, gated decode only: - - Keep `spo2CandidateRaw` as-is; add a pinned, documented interpretation - (bit 7 flag, value = low 7 bits) behind a plausibility gate, still - labeled candidate until validated. + - Keep `spo2CandidateRaw` as-is. Do not document any interpretation of the + byte until the validation workstream pins its encoding; it stays labeled + candidate until validated. - Add a decoder for the 96-byte realtime packet exposing byte 5 as **raw + CRC flag only** — no percentage publishing. Resolve the characteristic UUID discrepancy from real captures first. @@ -121,16 +118,17 @@ the confidence level stated. is pinned and validated against a reference device; gen4 byte 5 stays diagnostic, never user-facing, until ground-truth validated. - Diagnostics stay RAM-ONLY, never in `_log`/FileLog. FileLog persists to - Android external storage (`lib/sync/file_log.dart`): on Android ≤10 other - apps with storage permission can read it (CWE-532), and on 11+ the - app-specific dir is no longer cross-app readable — but the data would - still be PERSISTED, which is the primary reason health diagnostics stay + disk (`lib/sync/file_log.dart`). Check where it lands on each Android + version before claiming who can read it; regardless of readability the + data would still be PERSISTED, which is the reason health diagnostics stay in RAM. - Respect invariants: live streams RAM-only (inv. 14), never fabricate a metric (inv. 3), bump `kAlgoVersion` when any output changes (inv. 4). 4. **Validation workstream** (prerequisite for ANY user-facing SpO2 value, both generations): paired measurements against a reference oximeter over - at least 10 nights / 30 paired samples; the display gate opens only when + at least 10 nights / 30 paired samples across at least 3 distinct wearers (one + wearer's nights can satisfy the counts alone, so the wearer minimum is + separate); the display gate opens only when accuracy criteria are met — MAE <= 2 pp AND |mean bias| <= 1 pp AND Bland-Altman 95% limits of agreement within +/-4 pp. Correlation (Pearson/Spearman) is reported separately and is NEVER treated as proof of From fc90e656bb97d5e0c9accc00d4e1acf5976d0d7f Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 10 Nov 2026 17:34:40 +0530 Subject: [PATCH 34/36] gen5 spo2 candidate: neutral docs, say it is live-path only, drop the comment-only block, test the wake state, drop unrelated coderabbit change --- .coderabbit.yaml | 2 -- lib/ble/ble_engine.dart | 6 ------ lib/data/models.dart | 17 ++++++----------- test/gen5_spo2_candidate_test.dart | 19 ++++++++++++++----- 4 files changed, 20 insertions(+), 24 deletions(-) diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 849ea7d60..770ba8d19 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -25,5 +25,3 @@ reviews: # and they are the least likely place for a shipped bug. Re-include them # for a PR that is mostly test work. - "!test/**" - auto_review: - drafts: true \ No newline at end of file diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index a14f61d17..21109483f 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -5782,12 +5782,6 @@ class BleEngine { // about a vessel: beat-detection quality, motion-artifact rejection, // corroborating the band's own RR intervals. build those, not a number. sample = decodeGen5HistoricalSample(frame.inner); - // The gen5 SpO2 candidate byte is never written to _log — FileLog lives in - // Android external storage, so a timestamped candidate/sleep line there - // would be readable by other apps (CWE-532). It is not persisted as a - // decoded column either; only whole UNDECODABLE frames reach raw_archive, - // which can contain the byte (kept for the commit-before-ACK invariant). - // In-memory carrier: Sample.spo2CandidateRaw — raw, never a percentage. } else if (recType != Record.r25 && kKnownRecordVersions.contains(recType)) { // Every gen4 layout version protocol has a field map for — not just diff --git a/lib/data/models.dart b/lib/data/models.dart index 6b127b382..35b772a14 100644 --- a/lib/data/models.dart +++ b/lib/data/models.dart @@ -155,17 +155,12 @@ class Sample { /// The gen5 v18 SpO2 candidate byte (protocol `spo2CandidateRaw`, /// inner[74]) — GEN5/MG ONLY, raw and unvalidated. /// - /// Zero in ~99% of records, nonzero only during band-declared sleep - /// (community-observed bursts of 30 s every 1,200 s), encoding not fully - /// pinned (values above 128 decompose as 128 + flag). Carried as the raw - /// byte for capture correlation; NEVER displayed or stored as a - /// percentage — see `Spo2Data` in `models/payloads.dart` for the refusal. - /// Not persisted (no column); in-memory diagnostic only, never logged - /// (FileLog is external storage — CWE-532). After the validation gate in - /// docs/proposals/whoop-spo2.md passes, this byte is the raw input that - /// feeds `Spo2Data` — the single publication path. The validation gate this - /// refers to lives in docs/proposals/whoop-spo2.md, added by PR #17 - /// (upstream OpenStrap/edge#675) — not part of this branch. + /// The encoding is unknown and no ground truth exists, so this is the opaque + /// byte as received: never gated, never shown, never stored or converted as + /// a percentage. It lives in memory only, on the live decode path: there is + /// no column, so a sample rebuilt from stored rows has it null. Never write + /// it to a log. The validation gate for any later use is in + /// docs/proposals/whoop-spo2.md. final int? spo2CandidateRaw; Sample({ diff --git a/test/gen5_spo2_candidate_test.dart b/test/gen5_spo2_candidate_test.dart index 4f7311017..432558fdb 100644 --- a/test/gen5_spo2_candidate_test.dart +++ b/test/gen5_spo2_candidate_test.dart @@ -1,8 +1,8 @@ // The gen5 v18 SpO2 candidate byte (protocol `spo2CandidateRaw`, inner[74]) // must be carried through `decodeGen5HistoricalSample` as a RAW byte — never -// gated, never converted to a percentage. The byte is unvalidated community -// knowledge (nonzero only in band-declared sleep bursts), so the contract -// under test is passthrough + null on gen4-shaped input, not a value claim. +// gated, never converted to a percentage. The byte is unvalidated, so the +// contract under test is passthrough + null on gen4-shaped input, not a value +// claim. import 'dart:typed_data'; @@ -13,12 +13,12 @@ import 'package:openstrap_edge/models/payloads.dart'; import 'package:openstrap_protocol/openstrap_protocol.dart'; /// A minimal decodable v18 inner: length 112, version byte 18 at inner[1]. -Uint8List v18Inner({int spo2Candidate = 0}) { +Uint8List v18Inner({int spo2Candidate = 0, int stateByte = 2}) { final b = Uint8List(kGen5V18InnerLen); b[1] = 18; final v = ByteData.sublistView(b); v.setUint32(7, 1780916150, Endian.little); // unix - b[73] = 2; // sleepStateByte: sleep + b[73] = stateByte; // sleep-state byte; the sleep code sits in bits 4-5 b[74] = spo2Candidate; return b; } @@ -31,6 +31,15 @@ void main() { expect(s.bandSleepState, isNotNull); }); + // The candidate is carried whatever the band says about sleep: the decoder + // must not gate it on the sleep code, so a nonzero byte with a wake state + // still comes through unchanged. + test('a nonzero candidate is carried with a wake sleep state too', () { + final s = decodeGen5HistoricalSample( + v18Inner(spo2Candidate: 97, stateByte: 0x00)); + expect(s!.spo2CandidateRaw, 97); + }); + test('zero candidate stays zero — absent measurement, not a gated null', () { final s = decodeGen5HistoricalSample(v18Inner(spo2Candidate: 0)); expect(s!.spo2CandidateRaw, 0); From aa5b16014e5eb211a64ae81bfea062853fcca116 Mon Sep 17 00:00:00 2001 From: Mohammad Abdul Sahil <127765312+abdulsaheel@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:16:11 +0530 Subject: [PATCH 35/36] main is red since #680: ru/th strings for the firmware warning, gallery case for it, taller surface for the picker search test --- lib/l10n/app_ru.arb | 5 ++++- lib/l10n/app_th.arb | 5 ++++- lib/ui2/profile/gallery.dart | 2 ++ test/russian_review_findings_test.dart | 4 ++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c86544a36..84bc7cfc2 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -7904,5 +7904,8 @@ "type": "int" } } - } + }, + "whoopFirmwareWarningTitle": "Пользователям WHOOP: не подключайтесь снова к приложению WHOOP", + "whoopFirmwareWarningAction": "Если вы пользуетесь OpenStrap или другим приложением с открытым кодом либо планируете перейти на него позже, не подключайте браслет к официальному приложению WHOOP снова. Оно может автоматически установить обновления прошивки. Удалите или отключите приложение WHOOP, чтобы оно не обновляло ваш браслет.", + "whoopFirmwareWarningDetails": "В сообщениях о прошивках WHOOP 5.0 / MG 50.43.1.0 и 50.43.2.0 описывается заблокированный доступ к данным. Bluetooth при этом может подключаться. OpenStrap не может выпустить подписанный WHOOP токен членства, необходимый для разблокировки затронутых браслетов. Мы работаем над решением, но исправления пока нет." } diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 924d3dbf9..6347b62ca 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -16325,5 +16325,8 @@ "type": "int" } } - } + }, + "whoopFirmwareWarningTitle": "ผู้ใช้ WHOOP: อย่าเชื่อมต่อกับแอป WHOOP อีก", + "whoopFirmwareWarningAction": "หากคุณใช้ OpenStrap หรือแอปโอเพนซอร์สอื่น หรือวางแผนจะเปลี่ยนไปใช้ในภายหลัง อย่าเชื่อมต่อสายรัดกับแอป WHOOP อย่างเป็นทางการอีก แอปอาจติดตั้งอัปเดตเฟิร์มแวร์โดยอัตโนมัติ ให้ถอนการติดตั้งหรือปิดใช้งานแอป WHOOP เพื่อไม่ให้แอปอัปเดตสายรัดของคุณ", + "whoopFirmwareWarningDetails": "มีรายงานเกี่ยวกับเฟิร์มแวร์ WHOOP 5.0 / MG เวอร์ชัน 50.43.1.0 และ 50.43.2.0 ว่าเข้าถึงข้อมูลไม่ได้ บลูทูธอาจยังเชื่อมต่อได้ OpenStrap ไม่สามารถออกโทเค็นสมาชิกที่ WHOOP ลงนามซึ่งจำเป็นต่อการปลดล็อกสายรัดที่ได้รับผลกระทบ เรากำลังหาทางแก้ไข แต่ยังไม่มีวิธีแก้ในตอนนี้" } diff --git a/lib/ui2/profile/gallery.dart b/lib/ui2/profile/gallery.dart index 02809f51c..5fd5a0966 100644 --- a/lib/ui2/profile/gallery.dart +++ b/lib/ui2/profile/gallery.dart @@ -53,6 +53,7 @@ import '../activity/poster.dart' show PosterCard, PosterFormat, PosterStatRow, kPosterMapH, kPosterMapW; import '../activity/setup.dart' show ActivitySetup; import '../activity/tiles.dart'; +import '../pairing/whoop_firmware_warning.dart'; import '../activity/share.dart' show ShareSheet, shareOrigin; import '../activity/summary.dart'; import '../onboarding/welcome.dart' show ImportOutcome, ImportReport; @@ -210,6 +211,7 @@ Map componentCases() => { 'ecg_reading_row': Surface( pad: EdgeInsets.zero, child: EcgReadingRow(reading: _ecgDemoReading, onTap: () {})), + 'whoop_firmware_warning': const WhoopFirmwareWarning(), 'signal': const SignalCard( LucideIcons.heartPulse, C.blue, 'Resting heart rate', '52', unit: 'bpm', sub: '4 BELOW YOUR BASELINE'), diff --git a/test/russian_review_findings_test.dart b/test/russian_review_findings_test.dart index 59ac92395..9bfbee005 100644 --- a/test/russian_review_findings_test.dart +++ b/test/russian_review_findings_test.dart @@ -327,6 +327,10 @@ void main() { ); testWidgets('category search accepts original and Russian blurb', (t) async { + // The firmware warning banner sits above the list; a tall surface keeps + // the filtered row built instead of lazily off-screen. + await t.binding.setSurfaceSize(const Size(800, 3000)); + addTearDown(() => t.binding.setSurfaceSize(null)); await t.pumpWidget(page(const DevicePickerScreen())); await t.pumpAndSettle(); final entry = kBleHrs; From f7cf721f290d46ba8805ad13303c04631f3672bb Mon Sep 17 00:00:00 2001 From: uvtqt Date: Wed, 11 Nov 2026 09:34:47 +0100 Subject: [PATCH 36/36] CI: split the test suite across six parallel jobs The suite ran as one job for about 75 minutes, and a newer push to main cancels the run in progress, so runs on main were cancelled about as often as they finished. Each slice still runs its files one at a time. A final job keeps the check name `test`. --- .github/scripts/test_shard.sh | 34 +++++++++++++++++++ .github/workflows/test.yml | 61 ++++++++++++++++++++++++++++------- 2 files changed, 84 insertions(+), 11 deletions(-) create mode 100755 .github/scripts/test_shard.sh diff --git a/.github/scripts/test_shard.sh b/.github/scripts/test_shard.sh new file mode 100755 index 000000000..c80ea6509 --- /dev/null +++ b/.github/scripts/test_shard.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Run one slice of the test suite: test_shard.sh . +# +# Every *_test.dart under test/ belongs to exactly one slice (its position in +# the sorted list, modulo ), so the slices together are the whole suite. +# Inside a slice the files still run one at a time (--concurrency=1): the suite +# uses sqflite_common_ffi against real database files and parallel workers on +# ONE machine race on them. Separate CI jobs are separate machines. +# +# ponytail: slices are balanced by file COUNT, not by duration. If one slice +# becomes the slow one, split by recorded timings instead. +set -euo pipefail + +index="$1" +total="$2" + +files=() +i=0 +while IFS= read -r f; do + if [ $((i % total)) -eq "$index" ]; then files+=("$f"); fi + i=$((i + 1)) +done < <(find test -name '*_test.dart' | LC_ALL=C sort) + +if [ "${#files[@]}" -eq 0 ]; then + echo "slice $index of $total holds no test files" >&2 + exit 1 +fi + +echo "slice $index of $total: ${#files[@]} of $i test files" +if [ "${TEST_SHARD_LIST_ONLY:-}" = 1 ]; then + printf '%s\n' "${files[@]}" + exit 0 +fi +flutter test --concurrency=1 --reporter=expanded "${files[@]}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 64385d7c9..e48ae940e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,9 @@ permissions: contents: read jobs: - test: + # Analyze, the lock guard and the zone-dependent tests: everything that is + # not the main suite. + checks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -68,17 +70,8 @@ jobs: - name: Analyze run: flutter analyze - # --concurrency=1: the suite uses sqflite_common_ffi against real database - # files, and parallel workers race on them. - # - # The golden capture (whoop_hist.jsonl) is a real band recording kept - # beside the repo rather than committed to it, so the two derivation-replay - # tests SKIP here and run locally. - - name: Test - run: flutter test --concurrency=1 --reporter=expanded - # The WHOOP freeze goldens are per timezone (day labels are local), and - # the run above is UTC, so it only enforces the *_utc files and the other + # the main suite runs in UTC, so it only enforces the *_utc files and the other # two skip. Re-run that one file under each committed offset. The Mi # scale clock test reads the process zone and is a no-op under UTC, so it # rides along. @@ -86,3 +79,49 @@ jobs: run: | TZ=Asia/Kolkata flutter test --concurrency=1 test/whoop_freeze_golden_test.dart test/adapters/miscale_test.dart TZ=America/New_York flutter test --concurrency=1 test/whoop_freeze_golden_test.dart test/adapters/miscale_test.dart + + # The main suite, split across machines. It ran as ONE job for about 75 + # minutes, and a newer push to main cancels the run in progress + # (`cancel-in-progress` above), so runs on main were cancelled about as often + # as they finished. Each slice still runs its files one at a time; see + # .github/scripts/test_shard.sh. + # + # The golden capture (whoop_hist.jsonl) is a real band recording kept beside + # the repo rather than committed to it, so the two derivation-replay tests + # SKIP here and run locally. + suite: + runs-on: ubuntu-latest + strategy: + # One failing slice must not hide failures in the others. + fail-fast: false + matrix: + shard: [0, 1, 2, 3, 4, 5] + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # Same pin as `checks`; see the note there. + - uses: subosito/flutter-action@v2 + with: + flutter-version: 3.41.6 + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Test (slice ${{ matrix.shard }} of 6) + run: bash .github/scripts/test_shard.sh ${{ matrix.shard }} 6 + + # One check with the old job's name, so anything that waits for `test` + # (branch protection, a badge) keeps working. Fails unless every job above + # passed. + test: + if: always() + needs: [checks, suite] + runs-on: ubuntu-latest + steps: + - name: All checks and slices passed + run: | + [ "${{ needs.checks.result }}" = success ] && [ "${{ needs.suite.result }}" = success ]