Files
GameTime/lib/infrastructure/watch_bridge/wear_data_layer_adapter.dart
Blomios 7130635177 fix(watch): stabilise stats live, foreground et resync apres perte de connexion (#179-#189)
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>
2026-07-30 11:32:30 +02:00

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