Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 20 additions & 11 deletions lib/health/health_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand All @@ -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,
];

Expand All @@ -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<HealthDataType> 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,
];

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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', '');
Expand All @@ -923,7 +932,7 @@ class HealthExporter {
try {
return healthDeleteClearedRange(
deleted: await _nativeDelete(HealthDataType.STEPS, start, end),
ios: isApple,
ios: _apple,
);
} on TimeoutException {
_storeHung = true;
Expand Down Expand Up @@ -1246,7 +1255,7 @@ class HealthExporter {
success = false;
}
}
if (isApple) {
if (_apple) {
try {
if (!await _appleSleep.replace(
bundle: b,
Expand Down Expand Up @@ -1418,7 +1427,7 @@ class HealthExporter {
);
await writeAt(
_hrvType,
healthHrvExportValue(b, apple: isApple),
healthHrvExportValue(b, apple: _apple),
HealthDataUnit.MILLISECOND,
mid,
);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
19 changes: 10 additions & 9 deletions test/health_workout_export_delete_gate_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,17 @@ 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());

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'));
Expand All @@ -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')));
Expand All @@ -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']));
Expand All @@ -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,
});
Expand All @@ -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');
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Loading