Reduit le cout radio des samples live et la cadence des projections telephone -> montre (#179-#183). Restaure les statistiques live FC/distance/calories et le maintien foreground/ongoing activity (#184-#185). Fiabilise le demarrage de seance et l'orchestration des permissions montre (#187). Renforce la resynchronisation des statistiques live et du score apres perte puis retour de connexion (#188-#189). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
352 lines
11 KiB
Dart
352 lines
11 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
|
|
|
|
import '../../application/use_cases.dart';
|
|
import '../../application/watch_companion_use_cases.dart';
|
|
import 'native_watch_bridge_channel.dart';
|
|
|
|
final class WatchWearDataLayerAdapter
|
|
implements WatchProjectionPublisher, WatchAlertPublisher {
|
|
WatchWearDataLayerAdapter({
|
|
required WatchBridgeNativeChannel nativeChannel,
|
|
required WatchCommandIngress commandIngress,
|
|
required WatchProjectionSource projectionSource,
|
|
WorkoutHistoryUseCases? workoutHistoryUseCases,
|
|
ActiveWorkoutSensorUseCases? activeWorkoutSensorUseCases,
|
|
WorkoutTelemetryUseCases? workoutTelemetryUseCases,
|
|
Duration projectionRefreshInterval = const Duration(seconds: 5),
|
|
}) : _nativeChannel = nativeChannel,
|
|
_commandIngress = commandIngress,
|
|
_projectionSource = projectionSource,
|
|
_workoutHistoryUseCases = workoutHistoryUseCases,
|
|
_activeWorkoutSensorUseCases = activeWorkoutSensorUseCases,
|
|
_workoutTelemetryUseCases = workoutTelemetryUseCases,
|
|
_projectionRefreshInterval = projectionRefreshInterval;
|
|
|
|
final WatchBridgeNativeChannel _nativeChannel;
|
|
final WatchCommandIngress _commandIngress;
|
|
final WatchProjectionSource _projectionSource;
|
|
final WorkoutHistoryUseCases? _workoutHistoryUseCases;
|
|
final ActiveWorkoutSensorUseCases? _activeWorkoutSensorUseCases;
|
|
final WorkoutTelemetryUseCases? _workoutTelemetryUseCases;
|
|
final Duration _projectionRefreshInterval;
|
|
final _commandAcks = <_WatchAdapterCommandKey, WatchCommandAck>{};
|
|
final _subscriptions = <StreamSubscription<dynamic>>[];
|
|
Future<void> _commandTail = Future<void>.value();
|
|
Timer? _projectionRefreshTimer;
|
|
WatchSessionProjection? _latestProjection;
|
|
WatchSessionProjection? _lastPublishedProjection;
|
|
bool _skipNextProjectionEmissionForForcedResync = false;
|
|
int? _lastPublishedProjectionRevision;
|
|
bool _started = false;
|
|
bool _foregroundActive = false;
|
|
|
|
Future<void> start() async {
|
|
if (_started) {
|
|
return;
|
|
}
|
|
_started = true;
|
|
_ensureProjectionRefreshLoop();
|
|
_subscriptions.add(
|
|
_projectionSource.projections.listen((projection) {
|
|
if (_skipNextProjectionEmissionForForcedResync) {
|
|
_skipNextProjectionEmissionForForcedResync = false;
|
|
return;
|
|
}
|
|
unawaited(publish(projection));
|
|
}),
|
|
);
|
|
_subscriptions.add(
|
|
_nativeChannel.commands.listen((command) {
|
|
unawaited(_enqueueCommand(command));
|
|
}),
|
|
);
|
|
final workoutHistoryUseCases = _workoutHistoryUseCases;
|
|
if (workoutHistoryUseCases != null) {
|
|
_subscriptions.add(
|
|
_nativeChannel.sensorSummaries.listen((summary) {
|
|
unawaited(workoutHistoryUseCases.updateHeartRateSummary(summary));
|
|
}),
|
|
);
|
|
}
|
|
final activeWorkoutSensorUseCases = _activeWorkoutSensorUseCases;
|
|
final workoutTelemetryUseCases = _workoutTelemetryUseCases;
|
|
if (activeWorkoutSensorUseCases != null ||
|
|
workoutTelemetryUseCases != null) {
|
|
_subscriptions.add(
|
|
_nativeChannel.sensorSamples.listen((sample) {
|
|
activeWorkoutSensorUseCases?.recordTelemetrySample(sample);
|
|
unawaited(workoutTelemetryUseCases?.recordTelemetrySample(sample));
|
|
}),
|
|
);
|
|
}
|
|
_subscriptions.add(
|
|
_nativeChannel.connectionEvents.listen((event) {
|
|
if (event.isReachable || event.requestsResync) {
|
|
unawaited(_forceProjectionResync());
|
|
}
|
|
}),
|
|
);
|
|
await _projectionSource.emitCurrentProjection();
|
|
await _nativeChannel.requestCapabilityRefresh();
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
_projectionRefreshTimer?.cancel();
|
|
_projectionRefreshTimer = null;
|
|
for (final subscription in _subscriptions) {
|
|
await subscription.cancel();
|
|
}
|
|
_subscriptions.clear();
|
|
_started = false;
|
|
}
|
|
|
|
@override
|
|
Future<void> publish(
|
|
WatchSessionProjection projection, {
|
|
bool urgent = true,
|
|
}) async {
|
|
await _publishProjection(projection, urgent: urgent, force: false);
|
|
}
|
|
|
|
Future<void> _publishProjection(
|
|
WatchSessionProjection projection, {
|
|
required bool urgent,
|
|
required bool force,
|
|
}) async {
|
|
final previousProjection = _latestProjection;
|
|
_latestProjection = projection;
|
|
if (projection.phase == WatchSessionPhase.noActiveSession) {
|
|
final previousSessionId = previousProjection?.deviceSessionId;
|
|
if (previousSessionId != null && previousSessionId.isNotEmpty) {
|
|
_activeWorkoutSensorUseCases?.clear(previousSessionId);
|
|
}
|
|
}
|
|
if (!force &&
|
|
_lastPublishedProjection != null &&
|
|
_hasSameSignificantProjectionState(
|
|
_lastPublishedProjection!,
|
|
projection,
|
|
)) {
|
|
await _syncForegroundService(projection);
|
|
return;
|
|
}
|
|
final revisionChanged =
|
|
_lastPublishedProjectionRevision != projection.revision;
|
|
await _nativeChannel.publishProjection(
|
|
projection,
|
|
urgent: force ? urgent : urgent && revisionChanged,
|
|
);
|
|
_lastPublishedProjection = projection;
|
|
_lastPublishedProjectionRevision = projection.revision;
|
|
await _syncForegroundService(projection);
|
|
}
|
|
|
|
@override
|
|
Future<void> publishAlert(WatchAlertEnvelope alert) {
|
|
return _nativeChannel.publishAlert(alert);
|
|
}
|
|
|
|
Future<void> _enqueueCommand(WatchCommandEnvelope command) {
|
|
final run = _commandTail.then(
|
|
(_) => _handleCommand(command),
|
|
onError: (_) => _handleCommand(command),
|
|
);
|
|
_commandTail = run.then((_) {}, onError: (_) {});
|
|
return run;
|
|
}
|
|
|
|
Future<void> _handleCommand(WatchCommandEnvelope command) async {
|
|
final key = _WatchAdapterCommandKey(command);
|
|
final cachedAck = _commandAcks[key];
|
|
if (cachedAck != null) {
|
|
await _sendAck(command, WatchCommandAck.acceptedNoOp);
|
|
return;
|
|
}
|
|
final ack = await _commandIngress.dispatch(command);
|
|
if (ack == WatchCommandAck.accepted ||
|
|
ack == WatchCommandAck.acceptedNoOp) {
|
|
_rememberAck(key, ack);
|
|
}
|
|
await _sendAck(command, ack);
|
|
}
|
|
|
|
Future<void> _sendAck(
|
|
WatchCommandEnvelope command,
|
|
WatchCommandAck ack,
|
|
) async {
|
|
int? revisionAtAck;
|
|
try {
|
|
revisionAtAck = (await _projectionSource.currentProjection()).revision;
|
|
} on Exception {
|
|
revisionAtAck = _latestProjection?.revision;
|
|
}
|
|
await _nativeChannel.sendCommandAck(
|
|
command,
|
|
ack,
|
|
revisionAtAck: revisionAtAck,
|
|
);
|
|
}
|
|
|
|
void _rememberAck(_WatchAdapterCommandKey key, WatchCommandAck ack) {
|
|
_commandAcks[key] = ack;
|
|
if (_commandAcks.length <= 128) {
|
|
return;
|
|
}
|
|
_commandAcks.remove(_commandAcks.keys.first);
|
|
}
|
|
|
|
Future<void> _syncForegroundService(WatchSessionProjection projection) async {
|
|
final shouldRun =
|
|
projection.phase != WatchSessionPhase.noActiveSession &&
|
|
projection.deviceSessionId.isNotEmpty;
|
|
if (shouldRun == _foregroundActive) {
|
|
return;
|
|
}
|
|
_foregroundActive = shouldRun;
|
|
if (shouldRun) {
|
|
await _nativeChannel.startForegroundService();
|
|
} else {
|
|
await _nativeChannel.stopForegroundService();
|
|
}
|
|
}
|
|
|
|
void _ensureProjectionRefreshLoop() {
|
|
_projectionRefreshTimer ??= Timer.periodic(_projectionRefreshInterval, (_) {
|
|
unawaited(_publishHeartbeat());
|
|
});
|
|
}
|
|
|
|
Future<void> _publishHeartbeat() async {
|
|
final projection = await _emitCurrentProjectionSkippingSourceEcho();
|
|
await _publishProjection(projection, urgent: false, force: true);
|
|
}
|
|
|
|
Future<void> _forceProjectionResync() async {
|
|
final projection = await _emitCurrentProjectionSkippingSourceEcho();
|
|
await _publishProjection(projection, urgent: true, force: true);
|
|
}
|
|
|
|
Future<WatchSessionProjection>
|
|
_emitCurrentProjectionSkippingSourceEcho() async {
|
|
_skipNextProjectionEmissionForForcedResync = true;
|
|
try {
|
|
return await _projectionSource.emitCurrentProjection();
|
|
} finally {
|
|
unawaited(
|
|
Future<void>.delayed(Duration.zero, () {
|
|
_skipNextProjectionEmissionForForcedResync = false;
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _hasSameSignificantProjectionState(
|
|
WatchSessionProjection left,
|
|
WatchSessionProjection right,
|
|
) {
|
|
return left.schemaVersion == right.schemaVersion &&
|
|
left.deviceSessionId == right.deviceSessionId &&
|
|
left.revision == right.revision &&
|
|
left.phase == right.phase &&
|
|
left.phoneReachable == right.phoneReachable &&
|
|
left.seriesIndex == right.seriesIndex &&
|
|
left.seriesTotal == right.seriesTotal &&
|
|
left.exerciseName == right.exerciseName &&
|
|
left.programIndex == right.programIndex &&
|
|
left.exerciseIndex == right.exerciseIndex &&
|
|
left.setIndex == right.setIndex &&
|
|
left.passageIndex == right.passageIndex &&
|
|
left.passageTotal == right.passageTotal &&
|
|
left.stepIndex == right.stepIndex &&
|
|
left.stepTotal == right.stepTotal &&
|
|
left.stepName == right.stepName &&
|
|
left.stepType == right.stepType &&
|
|
left.stepTargetValue == right.stepTargetValue &&
|
|
_hasSameSignificantTimerState(left.dominantTimer, right.dominantTimer) &&
|
|
_hasSameSignificantTimerListState(
|
|
left.secondaryTimers,
|
|
right.secondaryTimers,
|
|
) &&
|
|
left.primaryAction == right.primaryAction &&
|
|
_listEquals(left.secondaryActions, right.secondaryActions) &&
|
|
left.nextExerciseName == right.nextExerciseName &&
|
|
left.statusLabel == right.statusLabel &&
|
|
left.hasManualScore == right.hasManualScore &&
|
|
left.currentManualScoreValue == right.currentManualScoreValue &&
|
|
left.canDecrementScore == right.canDecrementScore &&
|
|
left.manualScoreTargetValue == right.manualScoreTargetValue &&
|
|
left.manualScoreTargetLabel == right.manualScoreTargetLabel &&
|
|
left.manualScoreRepsTargetValue == right.manualScoreRepsTargetValue &&
|
|
left.manualScoreScope == right.manualScoreScope;
|
|
}
|
|
|
|
bool _hasSameSignificantTimerListState(
|
|
List<WatchTimerProjection> left,
|
|
List<WatchTimerProjection> right,
|
|
) {
|
|
if (left.length != right.length) {
|
|
return false;
|
|
}
|
|
for (var index = 0; index < left.length; index += 1) {
|
|
if (!_hasSameSignificantTimerState(left[index], right[index])) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool _hasSameSignificantTimerState(
|
|
WatchTimerProjection? left,
|
|
WatchTimerProjection? right,
|
|
) {
|
|
if (left == null || right == null) {
|
|
return left == right;
|
|
}
|
|
return left.kind == right.kind &&
|
|
left.label == right.label &&
|
|
left.displayMode == right.displayMode &&
|
|
left.runState == right.runState &&
|
|
left.targetMs == right.targetMs;
|
|
}
|
|
|
|
bool _listEquals<T>(List<T> left, List<T> right) {
|
|
if (left.length != right.length) {
|
|
return false;
|
|
}
|
|
for (var index = 0; index < left.length; index += 1) {
|
|
if (left[index] != right[index]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
final class _WatchAdapterCommandKey {
|
|
_WatchAdapterCommandKey(WatchCommandEnvelope command)
|
|
: sessionId = command.sessionId,
|
|
expectedRevision = command.expectedRevision,
|
|
commandId = command.commandId,
|
|
type = command.type;
|
|
|
|
final String sessionId;
|
|
final int expectedRevision;
|
|
final String commandId;
|
|
final WatchCommandType type;
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return identical(this, other) ||
|
|
other is _WatchAdapterCommandKey &&
|
|
sessionId == other.sessionId &&
|
|
expectedRevision == other.expectedRevision &&
|
|
commandId == other.commandId &&
|
|
type == other.type;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(sessionId, expectedRevision, commandId, type);
|
|
}
|