From 7382f7ebf53e787cb6348c54fb3af708757db80c Mon Sep 17 00:00:00 2001 From: uvtqt Date: Tue, 10 Nov 2026 14:24:04 +0100 Subject: [PATCH 1/2] Pin the Health Connect delete-gate test to Health Connect The `exportWorkout delete-then-write` group tests the Health Connect reading of a false delete, and assumed a non-Apple host. flutter test on a Mac runs on a macOS host VM, where HealthExporter.isApple is true: the exporter takes the HealthKit branch (a false delete is the documented empty-range answer and the write proceeds, by design) and "a failed delete does not write a duplicate" fails. CI runs on Linux, so only contributors on a Mac saw it. Add a @visibleForTesting HealthExporter.isAppleOverride (null = the real platform, so the app is unchanged), the same pattern as ScreenWake.platformOverride, and pin it to false in that group. --- lib/health/health_export.dart | 9 ++++++++- test/health_workout_export_delete_gate_test.dart | 11 ++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 0e227ee9d..46fe50799 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -625,7 +625,14 @@ class HealthExporter { } /// True on iOS/macOS (Apple Health); false on Android (Health Connect). - static bool get isApple => Platform.isIOS || Platform.isMacOS; + static bool get isApple => + isAppleOverride ?? (Platform.isIOS || Platform.isMacOS); + + /// Pins [isApple] in a test. The host VM is macOS on a Mac and Linux in CI, + /// so a test of one store's behaviour otherwise passes or fails depending on + /// whose machine runs it. null = the real platform. + @visibleForTesting + static bool? isAppleOverride; /// Display name of the platform health store. static String get storeName => isApple ? 'Apple Health' : 'Health Connect'; diff --git a/test/health_workout_export_delete_gate_test.dart b/test/health_workout_export_delete_gate_test.dart index f4123438c..7b1f824d8 100644 --- a/test/health_workout_export_delete_gate_test.dart +++ b/test/health_workout_export_delete_gate_test.dart @@ -85,11 +85,16 @@ void main() { }); group('exportWorkout delete-then-write', () { - // The host VM is neither iOS nor macOS, so `HealthExporter.isApple` is - // false and these exercise the Health-Connect reading of `delete`. + // These exercise the Health-Connect reading of `delete`. Pinned, not left + // to the host: on a Mac the host VM is macOS, `isApple` was true, and the + // first test failed there while passing in CI (Linux). late _FakeHealthStore store; - tearDown(() => store.remove()); + setUp(() => HealthExporter.isAppleOverride = false); + tearDown(() { + store.remove(); + HealthExporter.isAppleOverride = null; + }); test('a failed delete does not write a duplicate on top of it', () async { store = _FakeHealthStore(deleteResult: false)..install(); From 72592d78d9a57824110fe9e5aef16ccc372fc21a Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Fri, 13 Nov 2026 10:58:46 +0530 Subject: [PATCH 2/2] health export: pin the store through the exporter's constructor instead of a static switch in production code --- lib/health/health_export.dart | 38 ++++++++++--------- ...ealth_workout_export_delete_gate_test.dart | 20 ++++------ 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index d4136739d..75233b81f 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -538,10 +538,14 @@ class HealthExporter { /// behind the purge: exportAll keeps it pending without spending an attempt. bool _dayHeldForPurge = false; + final bool? _isAppleOverride; + HealthExporter({ HealthConnectHeartRateWriter? androidHeartRate, @visibleForTesting Duration deleteTimeout = const Duration(seconds: 30), - }) : _androidHeartRate = + @visibleForTesting bool? isApple, + }) : _isAppleOverride = isApple, + _androidHeartRate = androidHeartRate ?? MethodChannelHealthConnectHeartRateWriter(), _deleteTimeout = deleteTimeout; @@ -626,21 +630,19 @@ class HealthExporter { } /// True on iOS/macOS (Apple Health); false on Android (Health Connect). - static bool get isApple => - isAppleOverride ?? (Platform.isIOS || Platform.isMacOS); + static bool get isApple => Platform.isIOS || Platform.isMacOS; - /// Pins [isApple] in a test. The host VM is macOS on a Mac and Linux in CI, - /// so a test of one store's behaviour otherwise passes or fails depending on - /// whose machine runs it. null = the real platform. - @visibleForTesting - static bool? isAppleOverride; + /// [isApple], or what a test pinned through the constructor. The host VM is + /// macOS on a Mac and Linux in CI, so a test of one store's behaviour + /// otherwise passes or fails depending on whose machine runs it. + bool get _apple => _isAppleOverride ?? isApple; /// Display name of the platform health store. static String get storeName => isApple ? 'Apple Health' : 'Health Connect'; /// HealthKit only stores SDNN; Health Connect stores RMSSD — write whichever /// the platform supports (both are real, from cleaned RR). - HealthDataType get _hrvType => isApple + HealthDataType get _hrvType => _apple ? HealthDataType.HEART_RATE_VARIABILITY_SDNN : HealthDataType.HEART_RATE_VARIABILITY_RMSSD; @@ -659,7 +661,7 @@ class HealthExporter { // and [_rewriteTypes]. HealthDataType.STEPS, for (final t in _sleepHealthTypes) - if (t != _foreignSleepEnvelope(isApple)) t, + if (t != _foreignSleepEnvelope(_apple)) t, HealthDataType.WORKOUT, ]; @@ -683,7 +685,7 @@ class HealthExporter { /// * HEART_RATE is removed on Apple too: the minute-HR block clears it /// itself, only over the minutes it still has rows for. List get _rewriteTypes => [ - for (final t in healthDeleteTypes(isApplePlatform: isApple)) + for (final t in healthDeleteTypes(isApplePlatform: _apple)) if (t != HealthDataType.STEPS && t != HealthDataType.HEART_RATE) t, ]; @@ -733,7 +735,7 @@ class HealthExporter { try { return healthDeleteClearedRange( deleted: await _nativeDelete(type, start, end), - ios: isApple, + ios: _apple, ); } catch (e) { if (e is TimeoutException) _storeHung = true; @@ -912,7 +914,7 @@ class HealthExporter { setCursor: (name, value) => LocalDb.setCursor(name, value), // Same platform flag the delete types use — the epoch rewrite is an // Apple-HealthKit concern only. - isApplePlatform: isApple, + isApplePlatform: _apple, ); if (reset) { await LocalDb.setCursor('health_export_through', ''); @@ -930,7 +932,7 @@ class HealthExporter { try { return healthDeleteClearedRange( deleted: await _nativeDelete(HealthDataType.STEPS, start, end), - ios: isApple, + ios: _apple, ); } on TimeoutException { _storeHung = true; @@ -1253,7 +1255,7 @@ class HealthExporter { success = false; } } - if (isApple) { + if (_apple) { try { if (!await _appleSleep.replace( bundle: b, @@ -1425,7 +1427,7 @@ class HealthExporter { ); await writeAt( _hrvType, - healthHrvExportValue(b, apple: isApple), + healthHrvExportValue(b, apple: _apple), HealthDataUnit.MILLISECOND, mid, ); @@ -1661,7 +1663,7 @@ class HealthExporter { activityType: _activity(r['type']?.toString()), start: DateTime.fromMillisecondsSinceEpoch(st * 1000), end: DateTime.fromMillisecondsSinceEpoch(en * 1000), - totalEnergyBurned: healthWorkoutEnergyKcal(r, isApplePlatform: isApple), + totalEnergyBurned: healthWorkoutEnergyKcal(r, isApplePlatform: _apple), title: healthWorkoutTitleForType(r['type']?.toString()), ); } catch (e) { @@ -1732,7 +1734,7 @@ class HealthExporter { // (the HRV type, the delete list, the store name) keys off the same getter, // and a divergence here would hand macOS the Health Connect spellings. HealthWorkoutActivityType _activity(String? type) => - healthActivityForType(type, ios: isApple); + healthActivityForType(type, ios: _apple); /// Decode a stored day bundle, normalizing the compact curve format back to /// plain [{t,v}] lists. Hypnogram segments are never encoded (no `t` key), so diff --git a/test/health_workout_export_delete_gate_test.dart b/test/health_workout_export_delete_gate_test.dart index 7b1f824d8..9a8b6409a 100644 --- a/test/health_workout_export_delete_gate_test.dart +++ b/test/health_workout_export_delete_gate_test.dart @@ -90,16 +90,12 @@ void main() { // first test failed there while passing in CI (Linux). late _FakeHealthStore store; - setUp(() => HealthExporter.isAppleOverride = false); - tearDown(() { - store.remove(); - HealthExporter.isAppleOverride = null; - }); + tearDown(() => store.remove()); test('a failed delete does not write a duplicate on top of it', () async { store = _FakeHealthStore(deleteResult: false)..install(); - final ok = await HealthExporter().exportWorkout(_session()); + final ok = await HealthExporter(isApple: false).exportWorkout(_session()); expect(ok, isFalse, reason: 'the caller must retry this workout'); expect(store.calls, contains('delete')); @@ -117,7 +113,7 @@ void main() { store = _FakeHealthStore(deleteResult: true, deleteThrows: true) ..install(); - final ok = await HealthExporter().exportWorkout(_session()); + final ok = await HealthExporter(isApple: false).exportWorkout(_session()); expect(ok, isFalse); expect(store.calls, isNot(contains('writeWorkoutData'))); @@ -126,7 +122,7 @@ void main() { test('a successful delete still writes', () async { store = _FakeHealthStore(deleteResult: true)..install(); - final ok = await HealthExporter().exportWorkout(_session()); + final ok = await HealthExporter(isApple: false).exportWorkout(_session()); expect(ok, isTrue); expect(store.calls, containsAllInOrder(['delete', 'writeWorkoutData'])); @@ -137,7 +133,7 @@ void main() { 'though it looks like any other finished row', () async { store = _FakeHealthStore(deleteResult: true)..install(); - final ok = await HealthExporter().exportWorkout({ + final ok = await HealthExporter(isApple: false).exportWorkout({ ..._session(), 'end_ts_fabricated': 1, }); @@ -161,7 +157,7 @@ void main() { test('a private session clears its window and is never written', () async { store = _FakeHealthStore(deleteResult: true)..install(); - await HealthExporter().exportWorkout({..._session(), 'private': 1}); + await HealthExporter(isApple: false).exportWorkout({..._session(), 'private': 1}); expect(store.calls, contains('delete'), reason: 'the stop-time export wrote it before the flag landed'); @@ -179,7 +175,7 @@ void main() { test('are never written to the health store', () async { store = _FakeHealthStore(deleteResult: true)..install(); - final ok = await HealthExporter().exportWorkout( + final ok = await HealthExporter(isApple: false).exportWorkout( {..._session(), 'source': kScoredImportSessionSource}); expect(ok, isFalse); @@ -190,7 +186,7 @@ void main() { test('an ordinary manual session still is', () async { store = _FakeHealthStore(deleteResult: true)..install(); - final ok = await HealthExporter() + final ok = await HealthExporter(isApple: false) .exportWorkout({..._session(), 'source': 'manual'}); expect(ok, isTrue);