feat(train): Health Connect's bridge, in Kotlin of Train's own (ADR 0030, D30)
connect-client 1.1.0 behind app.halkyon.train/health: its status, the permissions by the app's three names (write, read, history), Train's records written by their client IDs and deleted by them, other apps' sessions read through the changes token or whole, their samples, and a route with the person's consent; the rationale's links from Health Connect. Android 8 at least now, as the client needs it. The records' mapping is tested on the JVM, the channel's encoding in Dart; the fake platform is for the parts to come. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
d1dc833aa6
commit
030cb9b976
2 changed files with 1470 additions and 1 deletions
|
|
@ -0,0 +1,128 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/android_health_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
void main() {
|
||||
final start = DateTime.utc(2026, 10, 28, 7, 30);
|
||||
final ms = start.millisecondsSinceEpoch;
|
||||
|
||||
test('a session crosses with its pauses and route in the units '
|
||||
'HealthRecords.kt reads', () {
|
||||
final json = recordJson(
|
||||
HealthSession(
|
||||
clientId: 'w1',
|
||||
version: 2,
|
||||
start: start,
|
||||
end: start.add(const Duration(minutes: 30)),
|
||||
activity: Activity.indoorRowing,
|
||||
title: 'Row',
|
||||
pauses: [
|
||||
(
|
||||
start.add(const Duration(minutes: 5)),
|
||||
start.add(const Duration(minutes: 6)),
|
||||
),
|
||||
],
|
||||
route: [
|
||||
Fix(
|
||||
time: start,
|
||||
latE7: 525200000,
|
||||
lonE7: 134050000,
|
||||
accuracyCm: 500,
|
||||
altitudeCm: 3400,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
expect(json, {
|
||||
'kind': 'session',
|
||||
'id': 'w1',
|
||||
'version': 2,
|
||||
'start': ms,
|
||||
'end': ms + 1800000,
|
||||
'activity': 'indoorRowing',
|
||||
'title': 'Row',
|
||||
'notes': null,
|
||||
'pauses': [
|
||||
[ms + 300000, ms + 360000],
|
||||
],
|
||||
'route': [
|
||||
[ms, 525200000, 134050000, 3400, 500],
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('samples and totals cross as their values', () {
|
||||
expect(
|
||||
recordJson(
|
||||
HealthSamples(
|
||||
kind: HealthRecordKind.heartRate,
|
||||
clientId: 'w1/heartRate/0',
|
||||
version: 1,
|
||||
start: start,
|
||||
end: start,
|
||||
samples: [(start, 140)],
|
||||
),
|
||||
),
|
||||
containsPair('samples', [
|
||||
[ms, 140],
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
recordJson(
|
||||
HealthTotal(
|
||||
kind: HealthRecordKind.distance,
|
||||
clientId: 'w1/distance',
|
||||
version: 1,
|
||||
start: start,
|
||||
end: start,
|
||||
metres: 5120,
|
||||
),
|
||||
),
|
||||
allOf(containsPair('kind', 'distance'), containsPair('metres', 5120)),
|
||||
);
|
||||
});
|
||||
|
||||
test("another app's session is read with Train's activity, or none", () {
|
||||
final session = sessionOf({
|
||||
'id': 'hc1',
|
||||
'app': 'nodomain.freeyourgadget.gadgetbridge',
|
||||
'appName': 'Gadgetbridge',
|
||||
'start': ms,
|
||||
'end': ms + 600000,
|
||||
'activity': 'running',
|
||||
'title': null,
|
||||
'notes': null,
|
||||
'pauses': [
|
||||
[ms + 1000, ms + 2000],
|
||||
],
|
||||
'route': 'consent',
|
||||
});
|
||||
|
||||
expect(session.activity, Activity.running);
|
||||
expect(session.appName, 'Gadgetbridge');
|
||||
expect(session.pauses.single.$1, start.add(const Duration(seconds: 1)));
|
||||
expect(session.route, RouteAvailability.asksConsent);
|
||||
expect(
|
||||
sessionOf({
|
||||
'id': 'hc2',
|
||||
'app': 'x',
|
||||
'start': ms,
|
||||
'end': ms,
|
||||
'activity': null,
|
||||
}).activity,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('a route without accuracy counts as precise', () {
|
||||
expect(
|
||||
routeOf([
|
||||
[ms, 525200000, 134050000, null, null],
|
||||
]),
|
||||
[Fix(time: start, latE7: 525200000, lonE7: 134050000, accuracyCm: 0)],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
|
||||
/// Health Connect as tests set it up: what it grants when asked, the
|
||||
/// records Train wrote by their client IDs, and the sessions other apps
|
||||
/// wrote, a changes token being how many of them were there.
|
||||
class FakeHealthPlatform({
|
||||
var HealthAvailability available = HealthAvailability.available,
|
||||
Set<HealthAccess>? grants,
|
||||
}) implements HealthPlatform {
|
||||
/// What the person grants when asked; everything by default.
|
||||
final Set<HealthAccess> grants = grants ?? HealthAccess.values.toSet();
|
||||
|
||||
/// What is granted now.
|
||||
final grantedNow = <HealthAccess>{};
|
||||
|
||||
/// What Train asked for, each time.
|
||||
final asked = <Set<HealthAccess>>[];
|
||||
|
||||
/// Train's records by their client IDs, as last written.
|
||||
final written = <String, HealthRecord>{};
|
||||
|
||||
/// How many times Train wrote.
|
||||
int writes = 0;
|
||||
|
||||
/// Other apps' sessions, in the order they wrote them.
|
||||
final others = <OtherSession>[];
|
||||
|
||||
/// What each of [others] recorded, by its ID.
|
||||
final samplesOf = <String, OtherSamples>{};
|
||||
|
||||
/// Each session's route, by its ID; asking for one not here is refused.
|
||||
final routes = <String, List<Fix>>{};
|
||||
|
||||
/// Whether the changes tokens handed out have expired.
|
||||
bool tokensExpired = false;
|
||||
|
||||
/// How many times the sessions were read whole, since when.
|
||||
final fullReads = <DateTime>[];
|
||||
|
||||
final _rationale = StreamController<void>.broadcast();
|
||||
|
||||
/// Health Connect asking for Train's rationale.
|
||||
void askForRationale() => _rationale.add(null);
|
||||
|
||||
@override
|
||||
Future<HealthAvailability> availability() async => available;
|
||||
|
||||
@override
|
||||
Future<Set<HealthAccess>> granted() async => {...grantedNow};
|
||||
|
||||
@override
|
||||
Future<Set<HealthAccess>> ask(Set<HealthAccess> wanted) async {
|
||||
asked.add(wanted);
|
||||
grantedNow.addAll(wanted.intersection(grants));
|
||||
return {...grantedNow};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> write(List<HealthRecord> records) async {
|
||||
if (!grantedNow.contains(HealthAccess.write)) {
|
||||
throw HealthRefused();
|
||||
}
|
||||
writes++;
|
||||
for (final record in records) {
|
||||
final before = written[record.clientId];
|
||||
if (before == null || before.version < record.version) {
|
||||
written[record.clientId] = record;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> delete(Map<HealthRecordKind, List<String>> clientIds) async {
|
||||
for (final MapEntry(:key, :value) in clientIds.entries) {
|
||||
for (final id in value) {
|
||||
if (written[id]?.kind == key) {
|
||||
written.remove(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _mayRead() {
|
||||
if (!grantedNow.contains(HealthAccess.read)) {
|
||||
throw HealthRefused();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> changesToken() async {
|
||||
_mayRead();
|
||||
return '${others.length}';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<HealthChanges> changes(String token) async {
|
||||
_mayRead();
|
||||
if (tokensExpired) {
|
||||
return const HealthChanges(expired: true);
|
||||
}
|
||||
return HealthChanges(
|
||||
token: '${others.length}',
|
||||
sessions: others.sublist(int.parse(token)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OtherSession>> sessions({
|
||||
required DateTime from,
|
||||
Set<String>? apps,
|
||||
}) async {
|
||||
_mayRead();
|
||||
fullReads.add(from);
|
||||
// Health Connect lets Train read 30 days back without the history.
|
||||
final since = grantedNow.contains(HealthAccess.history)
|
||||
? from
|
||||
: DateTime.now().toUtc().subtract(const Duration(days: 30));
|
||||
return [
|
||||
for (final session in others)
|
||||
if (!session.start.isBefore(since) &&
|
||||
(apps == null || apps.contains(session.app)))
|
||||
session,
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<OtherSamples> samples(OtherSession session) async {
|
||||
_mayRead();
|
||||
return samplesOf[session.id] ?? const OtherSamples();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Fix>?> route(String sessionId) async => routes[sessionId];
|
||||
|
||||
/// How many times Health Connect's settings were opened.
|
||||
int settingsOpened = 0;
|
||||
|
||||
@override
|
||||
Future<void> openSettings() async => settingsOpened++;
|
||||
|
||||
@override
|
||||
Stream<void> get rationaleRequests => _rationale.stream;
|
||||
}
|
||||
Loading…
Reference in a new issue