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

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