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:
Tizian 「ティツィアーン」 2026-10-28 16:49:42 +01:00
commit 030cb9b976
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 1470 additions and 1 deletions

View file

@ -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)],
);
});
}

View file

@ -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;
}