diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 82af140b1..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; @@ -628,12 +632,17 @@ class HealthExporter { /// True on iOS/macOS (Apple Health); false on Android (Health Connect). static bool get isApple => Platform.isIOS || Platform.isMacOS; + /// [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; @@ -652,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, ]; @@ -676,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, ]; @@ -726,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; @@ -905,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', ''); @@ -923,7 +932,7 @@ class HealthExporter { try { return healthDeleteClearedRange( deleted: await _nativeDelete(HealthDataType.STEPS, start, end), - ios: isApple, + ios: _apple, ); } on TimeoutException { _storeHung = true; @@ -1246,7 +1255,7 @@ class HealthExporter { success = false; } } - if (isApple) { + if (_apple) { try { if (!await _appleSleep.replace( bundle: b, @@ -1418,7 +1427,7 @@ class HealthExporter { ); await writeAt( _hrvType, - healthHrvExportValue(b, apple: isApple), + healthHrvExportValue(b, apple: _apple), HealthDataUnit.MILLISECOND, mid, ); @@ -1654,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) { @@ -1725,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 f4123438c..9a8b6409a 100644 --- a/test/health_workout_export_delete_gate_test.dart +++ b/test/health_workout_export_delete_gate_test.dart @@ -85,8 +85,9 @@ 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()); @@ -94,7 +95,7 @@ void main() { 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')); @@ -112,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'))); @@ -121,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'])); @@ -132,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, }); @@ -156,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'); @@ -174,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); @@ -185,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);