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,198 @@
|
|||
import 'package:flutter/services.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';
|
||||
|
||||
/// Health Connect on Android, through the app's own `HealthConnectBridge`
|
||||
/// (Kotlin, `android/app/src/main/kotlin/`, ADR 0030, D30): times cross in
|
||||
/// UTC milliseconds, positions in 1e-7 degrees, lengths in centimetres.
|
||||
class AndroidHealthPlatform() implements HealthPlatform {
|
||||
static const _channel = MethodChannel('app.halkyon.train/health');
|
||||
static const _rationale = EventChannel('app.halkyon.train/health/rationale');
|
||||
|
||||
@override
|
||||
Future<HealthAvailability> availability() async =>
|
||||
switch (await _channel.invokeMethod<String>('status')) {
|
||||
'available' => HealthAvailability.available,
|
||||
'update' => HealthAvailability.needsUpdate,
|
||||
_ => HealthAvailability.unavailable,
|
||||
};
|
||||
|
||||
@override
|
||||
Future<Set<HealthAccess>> granted() async =>
|
||||
_accessOf(await _call<List<Object?>>('granted'));
|
||||
|
||||
@override
|
||||
Future<Set<HealthAccess>> ask(Set<HealthAccess> wanted) async => _accessOf(
|
||||
await _call<List<Object?>>('ask', [
|
||||
for (final access in wanted) access.name,
|
||||
]),
|
||||
);
|
||||
|
||||
@override
|
||||
Future<void> write(List<HealthRecord> records) =>
|
||||
_call<void>('write', [for (final record in records) recordJson(record)]);
|
||||
|
||||
@override
|
||||
Future<void> delete(Map<HealthRecordKind, List<String>> clientIds) =>
|
||||
_call<void>('delete', {
|
||||
for (final MapEntry(:key, :value) in clientIds.entries) key.name: value,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<String> changesToken() async => (await _call<String>('changesToken'))!;
|
||||
|
||||
@override
|
||||
Future<HealthChanges> changes(String token) async {
|
||||
final answer = (await _call<Map<Object?, Object?>>('changes', token))!;
|
||||
if (answer['expired'] == true) {
|
||||
return const HealthChanges(expired: true);
|
||||
}
|
||||
return HealthChanges(
|
||||
token: answer['token']! as String,
|
||||
sessions: [
|
||||
for (final session in answer['sessions']! as List<Object?>)
|
||||
sessionOf(session! as Map<Object?, Object?>),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<OtherSession>> sessions({
|
||||
required DateTime from,
|
||||
Set<String>? apps,
|
||||
}) async => [
|
||||
for (final session in (await _call<List<Object?>>('sessions', {
|
||||
'from': from.millisecondsSinceEpoch,
|
||||
'apps': apps?.toList(),
|
||||
}))!)
|
||||
sessionOf(session! as Map<Object?, Object?>),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<OtherSamples> samples(OtherSession session) async {
|
||||
final answer = (await _call<Map<Object?, Object?>>('samples', {
|
||||
'app': session.app,
|
||||
'start': session.start.millisecondsSinceEpoch,
|
||||
'end': session.end.millisecondsSinceEpoch,
|
||||
}))!;
|
||||
return OtherSamples(
|
||||
heartRates: [
|
||||
for (final sample in _rows(answer['heartRate']))
|
||||
HeartRate(time: _time(sample[0]! as int), bpm: sample[1]! as int),
|
||||
],
|
||||
metres: (answer['metres'] as num?)?.toDouble(),
|
||||
power: [
|
||||
for (final sample in _rows(answer['power']))
|
||||
(_time(sample[0]! as int), (sample[1]! as num).toDouble()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Fix>?> route(String sessionId) async {
|
||||
final route = await _call<List<Object?>>('route', sessionId);
|
||||
return route == null ? null : routeOf(route);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> openSettings() => _call<void>('openSettings');
|
||||
|
||||
@override
|
||||
Stream<void> get rationaleRequests => _rationale.receiveBroadcastStream();
|
||||
|
||||
/// [method] called, its refusal thrown as [HealthRefused].
|
||||
static Future<T?> _call<T>(String method, [Object? arguments]) async {
|
||||
try {
|
||||
return await _channel.invokeMethod<T>(method, arguments);
|
||||
} on PlatformException catch (error) {
|
||||
if (error.code == 'refused') {
|
||||
throw HealthRefused();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Set<HealthAccess> _accessOf(List<Object?>? names) => {
|
||||
for (final name in names ?? const [])
|
||||
?HealthAccess.values.asNameMap()[name],
|
||||
};
|
||||
}
|
||||
|
||||
/// [rows], a list of lists as the channel sends them; none for null.
|
||||
Iterable<List<Object?>> _rows(Object? rows) =>
|
||||
(rows as List<Object?>? ?? const []).cast<List<Object?>>();
|
||||
|
||||
DateTime _time(int milliseconds) =>
|
||||
DateTime.fromMillisecondsSinceEpoch(milliseconds, isUtc: true);
|
||||
|
||||
/// [record] as `HealthRecords.kt` reads it.
|
||||
Map<String, Object?> recordJson(HealthRecord record) => {
|
||||
'kind': record.kind.name,
|
||||
'id': record.clientId,
|
||||
'version': record.version,
|
||||
'start': record.start.millisecondsSinceEpoch,
|
||||
'end': record.end.millisecondsSinceEpoch,
|
||||
...switch (record) {
|
||||
HealthSession() => {
|
||||
'activity': record.activity.name,
|
||||
'title': record.title,
|
||||
'notes': record.notes,
|
||||
'pauses': [
|
||||
for (final (from, to) in record.pauses)
|
||||
[from.millisecondsSinceEpoch, to.millisecondsSinceEpoch],
|
||||
],
|
||||
'route': [
|
||||
for (final fix in record.route)
|
||||
[
|
||||
fix.time.millisecondsSinceEpoch,
|
||||
fix.latE7,
|
||||
fix.lonE7,
|
||||
fix.altitudeCm,
|
||||
fix.accuracyCm,
|
||||
],
|
||||
],
|
||||
},
|
||||
HealthSamples() => {
|
||||
'samples': [
|
||||
for (final (time, value) in record.samples)
|
||||
[time.millisecondsSinceEpoch, value],
|
||||
],
|
||||
},
|
||||
HealthTotal() => {'metres': record.metres},
|
||||
},
|
||||
};
|
||||
|
||||
/// Another app's session as `HealthRecords.kt` sends it.
|
||||
OtherSession sessionOf(Map<Object?, Object?> json) => OtherSession(
|
||||
id: json['id']! as String,
|
||||
app: json['app']! as String,
|
||||
appName: json['appName'] as String?,
|
||||
start: _time(json['start']! as int),
|
||||
end: _time(json['end']! as int),
|
||||
activity: Activity.values.asNameMap()[json['activity']],
|
||||
title: json['title'] as String?,
|
||||
notes: json['notes'] as String?,
|
||||
pauses: [
|
||||
for (final pause in _rows(json['pauses']))
|
||||
(_time(pause[0]! as int), _time(pause[1]! as int)),
|
||||
],
|
||||
route: switch (json['route']) {
|
||||
'data' => RouteAvailability.readable,
|
||||
'consent' => RouteAvailability.asksConsent,
|
||||
_ => RouteAvailability.none,
|
||||
},
|
||||
);
|
||||
|
||||
/// A route as `HealthRecords.kt` sends it; a location without its accuracy
|
||||
/// counts as precise, as the app that wrote it kept it.
|
||||
List<Fix> routeOf(List<Object?> route) => [
|
||||
for (final location in _rows(route))
|
||||
Fix(
|
||||
time: _time(location[0]! as int),
|
||||
latE7: location[1]! as int,
|
||||
lonE7: location[2]! as int,
|
||||
altitudeCm: location.elementAtOrNull(3) as int?,
|
||||
accuracyCm: location.elementAtOrNull(4) as int? ?? 0,
|
||||
),
|
||||
];
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/recording/model/recording.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
|
||||
/// Whether this device has Health Connect (ADR 0030, D31).
|
||||
enum HealthAvailability() {
|
||||
/// There, built into Android (14 on) or as its app.
|
||||
available,
|
||||
|
||||
/// Its app is there but too old for Train.
|
||||
needsUpdate,
|
||||
|
||||
/// Not there: before Android 14 without its app, or no Android at all.
|
||||
unavailable,
|
||||
}
|
||||
|
||||
/// What Train may do there, each one switch's permissions (D31).
|
||||
enum HealthAccess() {
|
||||
/// Writing its workouts: sessions, routes, heart rate, speed, power,
|
||||
/// distance and elevation gained.
|
||||
write,
|
||||
|
||||
/// Reading other apps' sessions, their heart rate, distance and power.
|
||||
read,
|
||||
|
||||
/// Reading what is older than 30 days before reading was granted.
|
||||
history,
|
||||
}
|
||||
|
||||
/// The kinds of record Train writes, by their names on the channel.
|
||||
enum HealthRecordKind() {
|
||||
session,
|
||||
heartRate,
|
||||
speed,
|
||||
power,
|
||||
distance,
|
||||
elevation,
|
||||
}
|
||||
|
||||
/// A record Train writes to Health Connect (D31): its client record ID,
|
||||
/// which a record written again with a higher [version] replaces, and the
|
||||
/// time it covers.
|
||||
sealed class const HealthRecord({
|
||||
required final String clientId,
|
||||
required final int version,
|
||||
required final DateTime start,
|
||||
required final DateTime end,
|
||||
}) with Equatable {
|
||||
/// What it is.
|
||||
HealthRecordKind get kind;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [kind, clientId, version, start, end];
|
||||
}
|
||||
|
||||
/// A workout as a session: its activity, name, notes, pauses and route.
|
||||
final class const HealthSession({
|
||||
required super.clientId,
|
||||
required super.version,
|
||||
required super.start,
|
||||
required super.end,
|
||||
required final Activity activity,
|
||||
final String? title,
|
||||
final String? notes,
|
||||
|
||||
/// Each pause, from its start to its end.
|
||||
final List<(DateTime, DateTime)> pauses = const [],
|
||||
|
||||
/// The fixes it moved by; none for a row.
|
||||
final List<Fix> route = const [],
|
||||
}) extends HealthRecord {
|
||||
@override
|
||||
HealthRecordKind get kind => HealthRecordKind.session;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
...super.props,
|
||||
activity,
|
||||
title,
|
||||
notes,
|
||||
pauses,
|
||||
route,
|
||||
];
|
||||
}
|
||||
|
||||
/// Samples of one kind over a stretch: the heart rate in beats a minute,
|
||||
/// the speed in metres a second, or power in watts.
|
||||
final class const HealthSamples({
|
||||
@override required final HealthRecordKind kind,
|
||||
required super.clientId,
|
||||
required super.version,
|
||||
required super.start,
|
||||
required super.end,
|
||||
required final List<(DateTime, num)> samples,
|
||||
}) extends HealthRecord {
|
||||
@override
|
||||
List<Object?> get props => [...super.props, samples];
|
||||
}
|
||||
|
||||
/// A total over the workout: its distance or elevation gained, in metres.
|
||||
final class const HealthTotal({
|
||||
@override required final HealthRecordKind kind,
|
||||
required super.clientId,
|
||||
required super.version,
|
||||
required super.start,
|
||||
required super.end,
|
||||
required final num metres,
|
||||
}) extends HealthRecord {
|
||||
@override
|
||||
List<Object?> get props => [...super.props, metres];
|
||||
}
|
||||
|
||||
/// Whether another app's session has a route Train may read.
|
||||
enum RouteAvailability() {
|
||||
/// There, readable.
|
||||
readable,
|
||||
|
||||
/// There, asking the person's consent for this session.
|
||||
asksConsent,
|
||||
|
||||
/// None.
|
||||
none,
|
||||
}
|
||||
|
||||
/// A session another app wrote (D31).
|
||||
final class const OtherSession({
|
||||
/// Health Connect's ID of it.
|
||||
required final String id,
|
||||
|
||||
/// The app that wrote it, its package, and its name where Android lets
|
||||
/// Train see it.
|
||||
required final String app,
|
||||
final String? appName,
|
||||
required final DateTime start,
|
||||
required final DateTime end,
|
||||
|
||||
/// Train's activity for it; null for one Train doesn't record.
|
||||
final Activity? activity,
|
||||
final String? title,
|
||||
final String? notes,
|
||||
final List<(DateTime, DateTime)> pauses = const [],
|
||||
final RouteAvailability route = RouteAvailability.none,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
app,
|
||||
appName,
|
||||
start,
|
||||
end,
|
||||
activity,
|
||||
title,
|
||||
notes,
|
||||
pauses,
|
||||
route,
|
||||
];
|
||||
}
|
||||
|
||||
/// What another app recorded over one of its sessions.
|
||||
final class const OtherSamples({
|
||||
final List<HeartRate> heartRates = const [],
|
||||
|
||||
/// Its distance, summed; null where it wrote none.
|
||||
final double? metres,
|
||||
|
||||
/// Its power, in watts.
|
||||
final List<(DateTime, double)> power = const [],
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [heartRates, metres, power];
|
||||
}
|
||||
|
||||
/// The sessions other apps wrote since a changes token, and the token to
|
||||
/// ask with next; [expired] where the token was too old, all to be read
|
||||
/// again.
|
||||
final class const HealthChanges({
|
||||
final String? token,
|
||||
final List<OtherSession> sessions = const [],
|
||||
final bool expired = false,
|
||||
}) with Equatable {
|
||||
@override
|
||||
List<Object?> get props => [token, sessions, expired];
|
||||
}
|
||||
|
||||
/// The person refused what Health Connect needed, or took it back.
|
||||
final class HealthRefused() implements Exception;
|
||||
|
||||
/// Health Connect on this device (D30): Android's, through Train's own
|
||||
/// Kotlin, or a fake in tests.
|
||||
abstract interface class HealthPlatform() {
|
||||
/// Whether it is here.
|
||||
Future<HealthAvailability> availability();
|
||||
|
||||
/// What Train is granted.
|
||||
Future<Set<HealthAccess>> granted();
|
||||
|
||||
/// Asks for [wanted] where not granted yet, and answers what is granted.
|
||||
Future<Set<HealthAccess>> ask(Set<HealthAccess> wanted);
|
||||
|
||||
/// Writes [records], each replacing the one of its client ID; throws
|
||||
/// [HealthRefused] without the permission.
|
||||
Future<void> write(List<HealthRecord> records);
|
||||
|
||||
/// Deletes Train's records of these client IDs.
|
||||
Future<void> delete(Map<HealthRecordKind, List<String>> clientIds);
|
||||
|
||||
/// A token to ask for the sessions written after now.
|
||||
Future<String> changesToken();
|
||||
|
||||
/// The sessions other apps wrote since [token].
|
||||
Future<HealthChanges> changes(String token);
|
||||
|
||||
/// Other apps' sessions since [from] (as far back as Health Connect lets
|
||||
/// Train read), of [apps] where given.
|
||||
Future<List<OtherSession>> sessions({
|
||||
required DateTime from,
|
||||
Set<String>? apps,
|
||||
});
|
||||
|
||||
/// What [session]'s app recorded over it.
|
||||
Future<OtherSamples> samples(OtherSession session);
|
||||
|
||||
/// [sessionId]'s route, asking the person where Health Connect wants it;
|
||||
/// null where they refused or there is none.
|
||||
Future<List<Fix>?> route(String sessionId);
|
||||
|
||||
/// Opens Health Connect's settings, where what Train is granted changes.
|
||||
Future<void> openSettings();
|
||||
|
||||
/// Fires when Health Connect asks for Train's rationale (D31).
|
||||
Stream<void> get rationaleRequests;
|
||||
}
|
||||
Loading…
Reference in a new issue