feat(train): other apps' workouts read from Health Connect (ADR 0030, D31)
Read from other apps, off until turned on, asks for reading with the history; the apps that wrote sessions are listed, each off. One turned on brings its sessions, earlier ones at once and later ones through the changes token as the app opens and comes to the front: each a workout of Train's own store, sealed and synced, its origin in its payload, its heart rate, pauses and a row's power as series, named by its app in the list and on its page. A double of a workout Train has brings at most its heart rate; one deleted in Train stays out; a track comes only when asked for on the page, Health Connect asking for consent. What came from Health Connect is never written back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
09f61c3b0a
commit
8fb2dbf35e
3 changed files with 1304 additions and 111 deletions
|
|
@ -0,0 +1,383 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/testing.dart';
|
||||
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/series_chunk.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/stored_workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
import '../../support/fake_health_platform.dart';
|
||||
import '../../support/memory_content_key_gateway.dart';
|
||||
import '../../support/memory_workout_gateway.dart';
|
||||
|
||||
/// Other apps' sessions brought in from Health Connect (ADR 0030, D31): off
|
||||
/// until the person turns it on, then from each app they turn on, each
|
||||
/// session a workout of Train's own store; one Train recorded already is a
|
||||
/// double, which brings at most its heart rate; one deleted in Train stays
|
||||
/// out.
|
||||
void main() {
|
||||
// Recent, as Health Connect lets Train read 30 days back without the
|
||||
// history.
|
||||
final today = DateTime.now().toUtc();
|
||||
final start = DateTime.utc(
|
||||
today.year,
|
||||
today.month,
|
||||
today.day,
|
||||
).subtract(const Duration(days: 2));
|
||||
DateTime at(int minutes) => start.add(Duration(minutes: minutes));
|
||||
const gadgetbridge = 'nodomain.freeyourgadget.gadgetbridge';
|
||||
const christopeit = 'com.vescape.christopeit';
|
||||
|
||||
late Directory folder;
|
||||
late TrainKeys keys;
|
||||
late WorkoutRepository workouts;
|
||||
late FakeHealthPlatform platform;
|
||||
final opened = <HealthConnect>[];
|
||||
|
||||
OtherSession session(
|
||||
String id, {
|
||||
String app = gadgetbridge,
|
||||
Activity? activity = Activity.running,
|
||||
int from = 0,
|
||||
int minutes = 30,
|
||||
List<(DateTime, DateTime)> pauses = const [],
|
||||
}) => OtherSession(
|
||||
id: id,
|
||||
app: app,
|
||||
appName: app == gadgetbridge ? 'Gadgetbridge' : null,
|
||||
start: at(from),
|
||||
end: at(from + minutes),
|
||||
activity: activity,
|
||||
title: 'Run $id',
|
||||
pauses: pauses,
|
||||
);
|
||||
|
||||
OtherSamples heart(int from, {double? metres}) => OtherSamples(
|
||||
heartRates: [
|
||||
for (var m = from; m < from + 30; m += 5)
|
||||
HeartRate(time: at(m), bpm: 140),
|
||||
],
|
||||
metres: metres,
|
||||
);
|
||||
|
||||
HealthConnect open() {
|
||||
final health = HealthConnect(
|
||||
platform,
|
||||
HealthStore(File('${folder.path}/health_connect.json')),
|
||||
workouts,
|
||||
)..start();
|
||||
opened.add(health);
|
||||
return health;
|
||||
}
|
||||
|
||||
Future<void> settled(HealthConnect health) async {
|
||||
await pumpEventQueue();
|
||||
await health.idle;
|
||||
await pumpEventQueue();
|
||||
await health.idle;
|
||||
}
|
||||
|
||||
Future<List<WorkoutContent>> contents() async => [
|
||||
for (final StoredWorkout(:content) in await workouts.all()) content!,
|
||||
];
|
||||
|
||||
/// Reading on, [apps] taken.
|
||||
Future<HealthConnect> reading(Set<String> apps) async {
|
||||
final health = open();
|
||||
await health.setReading(on: true);
|
||||
for (final app in apps) {
|
||||
await health.setApp(app, on: true);
|
||||
}
|
||||
await settled(health);
|
||||
return health;
|
||||
}
|
||||
|
||||
setUp(() {
|
||||
folder = Directory.systemTemp.createTempSync('health');
|
||||
keys = TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
|
||||
platform = FakeHealthPlatform();
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
for (final health in opened) {
|
||||
await health.dispose();
|
||||
}
|
||||
opened.clear();
|
||||
await workouts.dispose();
|
||||
keys.dispose();
|
||||
folder.deleteSync(recursive: true);
|
||||
});
|
||||
|
||||
test('nothing is read until the person turns it on', () async {
|
||||
platform.others.add(session('a'));
|
||||
final health = open();
|
||||
await settled(health);
|
||||
|
||||
expect(health.settings.reads, isFalse);
|
||||
expect(platform.asked, isEmpty);
|
||||
expect(await workouts.all(), isEmpty);
|
||||
});
|
||||
|
||||
test('turned on, it asks to read with the history, and lists the apps it '
|
||||
'saw, each off', () async {
|
||||
platform.others.addAll([
|
||||
session('a'),
|
||||
session('b', app: christopeit, activity: Activity.indoorRowing),
|
||||
]);
|
||||
final health = await reading({});
|
||||
|
||||
expect(platform.asked, [
|
||||
{HealthAccess.read, HealthAccess.history},
|
||||
]);
|
||||
expect(health.settings.reads, isTrue);
|
||||
expect(health.settings.apps, {
|
||||
gadgetbridge: const SourceApp(name: 'Gadgetbridge'),
|
||||
christopeit: const SourceApp(),
|
||||
});
|
||||
expect(await workouts.all(), isEmpty);
|
||||
});
|
||||
|
||||
test("an app turned on brings its sessions as workouts, another's stay "
|
||||
'out', () async {
|
||||
platform
|
||||
..others.addAll([
|
||||
session(
|
||||
'a',
|
||||
pauses: [(at(10), at(15))],
|
||||
),
|
||||
session('b', app: christopeit, from: 100),
|
||||
])
|
||||
..samplesOf['a'] = heart(0, metres: 5012.4);
|
||||
await reading({gadgetbridge});
|
||||
|
||||
final [imported] = await workouts.all();
|
||||
final content = imported.content!;
|
||||
expect(content.activity, Activity.running);
|
||||
expect(content.name, 'Run a');
|
||||
expect((content.startedAt, content.endedAt), (at(0), at(30)));
|
||||
expect(content.distanceMetres, 5012);
|
||||
expect(content.movingTime, const Duration(minutes: 25));
|
||||
expect(
|
||||
content.origin,
|
||||
const WorkoutOrigin(
|
||||
app: gadgetbridge,
|
||||
appName: 'Gadgetbridge',
|
||||
sessionId: 'a',
|
||||
),
|
||||
);
|
||||
final series = await workouts.series(imported.id);
|
||||
expect(
|
||||
series.map((chunk) => chunk.kind).toSet(),
|
||||
{SeriesKind.heartRate, SeriesKind.pauses},
|
||||
);
|
||||
});
|
||||
|
||||
test('sessions written later come with the next read; a new app shows, '
|
||||
'off', () async {
|
||||
final health = await reading({gadgetbridge});
|
||||
final fullReads = platform.fullReads.length;
|
||||
platform.others.addAll([
|
||||
session('a'),
|
||||
session('b', app: christopeit, from: 60),
|
||||
]);
|
||||
|
||||
await health.read();
|
||||
await settled(health);
|
||||
|
||||
expect((await contents()).map((content) => content.name), ['Run a']);
|
||||
expect(health.settings.apps[christopeit], const SourceApp());
|
||||
expect(
|
||||
platform.fullReads,
|
||||
hasLength(fullReads),
|
||||
reason: 'through the changes token',
|
||||
);
|
||||
});
|
||||
|
||||
test("a session of a kind Train doesn't record stays out", () async {
|
||||
platform.others.add(session('yoga', activity: null));
|
||||
await reading({gadgetbridge});
|
||||
|
||||
expect(await workouts.all(), isEmpty);
|
||||
});
|
||||
|
||||
test("a double of Train's own brings its heart rate where Train's has "
|
||||
'none, and is no workout of its own', () async {
|
||||
final own = await workouts.save(
|
||||
WorkoutContent(
|
||||
activity: Activity.running,
|
||||
startedAt: at(1),
|
||||
endedAt: at(29),
|
||||
),
|
||||
const [],
|
||||
);
|
||||
platform
|
||||
..others.add(session('a'))
|
||||
..samplesOf['a'] = heart(0);
|
||||
await reading({gadgetbridge});
|
||||
|
||||
expect(await workouts.all(), hasLength(1));
|
||||
final series = await workouts.series(own);
|
||||
expect(series.map((chunk) => chunk.kind).toSet(), {SeriesKind.heartRate});
|
||||
// Within Train's own times alone.
|
||||
expect(series.first.start, at(5));
|
||||
});
|
||||
|
||||
test("a double of Train's own with its heart rate brings nothing", () async {
|
||||
final own = await workouts.save(
|
||||
WorkoutContent(
|
||||
activity: Activity.running,
|
||||
startedAt: at(0),
|
||||
endedAt: at(30),
|
||||
),
|
||||
chunksOf(
|
||||
SeriesKind.heartRate,
|
||||
start: at(0),
|
||||
times: [0],
|
||||
columns: {
|
||||
'bpm': [100],
|
||||
},
|
||||
),
|
||||
);
|
||||
platform
|
||||
..others.add(session('a'))
|
||||
..samplesOf['a'] = heart(0);
|
||||
await reading({gadgetbridge});
|
||||
|
||||
expect(await workouts.all(), hasLength(1));
|
||||
final series = await workouts.series(own);
|
||||
expect(series.single.columns['bpm'], [100]);
|
||||
});
|
||||
|
||||
test('a session overlapping only a little is no double', () async {
|
||||
await workouts.save(
|
||||
WorkoutContent(
|
||||
activity: Activity.running,
|
||||
startedAt: at(25),
|
||||
endedAt: at(60),
|
||||
),
|
||||
const [],
|
||||
);
|
||||
platform.others.add(session('a'));
|
||||
await reading({gadgetbridge});
|
||||
|
||||
expect(await workouts.all(), hasLength(2));
|
||||
});
|
||||
|
||||
test('a session deleted in Train stays out, even when everything is read '
|
||||
'again', () async {
|
||||
platform.others.add(session('a'));
|
||||
final health = await reading({gadgetbridge});
|
||||
final [imported] = await workouts.all();
|
||||
|
||||
await workouts.delete(imported.id);
|
||||
await settled(health);
|
||||
final fullReads = platform.fullReads.length;
|
||||
platform.tokensExpired = true;
|
||||
await health.read();
|
||||
await settled(health);
|
||||
|
||||
expect(await workouts.all(), isEmpty);
|
||||
expect(health.settings.dismissed, {'a'});
|
||||
expect(platform.fullReads, hasLength(fullReads + 1));
|
||||
});
|
||||
|
||||
test('read again whole, nothing comes twice', () async {
|
||||
platform.others.add(session('a'));
|
||||
final health = await reading({gadgetbridge});
|
||||
|
||||
platform.tokensExpired = true;
|
||||
await health.read();
|
||||
await settled(health);
|
||||
|
||||
expect(await workouts.all(), hasLength(1));
|
||||
});
|
||||
|
||||
test('without the history, only the last 30 days come in', () async {
|
||||
platform = FakeHealthPlatform(grants: {HealthAccess.read})
|
||||
..others.addAll([
|
||||
session('recent'),
|
||||
OtherSession(
|
||||
id: 'old',
|
||||
app: gadgetbridge,
|
||||
start: start.subtract(const Duration(days: 60)),
|
||||
end: start.subtract(const Duration(days: 60, minutes: -30)),
|
||||
activity: Activity.running,
|
||||
),
|
||||
]);
|
||||
final health = await reading({gadgetbridge});
|
||||
|
||||
expect(health.settings.reads, isTrue);
|
||||
expect((await contents()).map((content) => content.name), ['Run recent']);
|
||||
});
|
||||
|
||||
test("what came from Health Connect isn't written back", () async {
|
||||
platform.others.add(session('a'));
|
||||
final health = await reading({gadgetbridge});
|
||||
await health.setWriting(on: true);
|
||||
await settled(health);
|
||||
|
||||
expect(platform.written, isEmpty);
|
||||
});
|
||||
|
||||
test('refused, reading stays off; taken back later, it turns itself '
|
||||
'off', () async {
|
||||
platform = FakeHealthPlatform(grants: {});
|
||||
final refused = open();
|
||||
expect(await refused.setReading(on: false), isFalse);
|
||||
expect(await refused.setReading(on: true), isFalse);
|
||||
expect(refused.settings.reads, isFalse);
|
||||
await refused.dispose();
|
||||
opened.remove(refused);
|
||||
|
||||
platform = FakeHealthPlatform();
|
||||
final health = await reading({gadgetbridge});
|
||||
platform.grantedNow.clear();
|
||||
await health.read();
|
||||
await settled(health);
|
||||
|
||||
expect(health.settings.reads, isFalse);
|
||||
expect(health.settings.readRefused, isTrue);
|
||||
});
|
||||
|
||||
test("an imported workout's track comes in when asked for", () async {
|
||||
platform
|
||||
..others.add(session('a'))
|
||||
..routes['a'] = [
|
||||
Fix(time: at(1), latE7: 1, lonE7: 2, accuracyCm: 0),
|
||||
Fix(time: at(2), latE7: 3, lonE7: 4, accuracyCm: 0),
|
||||
];
|
||||
final health = await reading({gadgetbridge});
|
||||
final [imported] = await workouts.all();
|
||||
|
||||
expect(await health.bringInTrack(imported.id), isTrue);
|
||||
|
||||
final series = await workouts.series(imported.id);
|
||||
final track = series.singleWhere(
|
||||
(chunk) => chunk.kind == SeriesKind.position,
|
||||
);
|
||||
expect(track.columns['lat'], [1, 3]);
|
||||
});
|
||||
|
||||
test('a track refused or missing brings nothing', () async {
|
||||
platform.others.add(session('a'));
|
||||
final health = await reading({gadgetbridge});
|
||||
final [imported] = await workouts.all();
|
||||
|
||||
expect(await health.bringInTrack(imported.id), isFalse);
|
||||
expect(
|
||||
(await workouts.series(imported.id)).map((chunk) => chunk.kind),
|
||||
isNot(contains(SeriesKind.position)),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -137,4 +137,12 @@ void main() {
|
|||
expect(blank.every((row) => row[1] == null && row[2] == null), isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
test('a series added later is kept with its workout (D31)', () async {
|
||||
final id = await workouts.save(ride, positions.take(1).toList());
|
||||
|
||||
await workouts.addSeries(id, positions.skip(1).toList());
|
||||
|
||||
expect(await workouts.series(id), positions);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,4 +67,21 @@ void main() {
|
|||
);
|
||||
expect(decodeWorkout(ByteData(0)), isNull);
|
||||
});
|
||||
|
||||
test('an imported workout keeps the app it came from, and its name and '
|
||||
'notes edited', () {
|
||||
final imported = WorkoutContent(
|
||||
activity: Activity.indoorRowing,
|
||||
startedAt: run.startedAt,
|
||||
endedAt: run.endedAt,
|
||||
origin: const WorkoutOrigin(
|
||||
app: 'com.vescape.christopeit',
|
||||
appName: 'Christopeit Connect',
|
||||
sessionId: 'hc-1',
|
||||
),
|
||||
);
|
||||
|
||||
expect(decodeWorkout(encodeWorkout(imported)), imported);
|
||||
expect(imported.withText(name: 'Intervals').origin, imported.origin);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue