aves_mio100/lib/remote/remote_ws_client.dart
2026-07-06 14:52:45 +02:00

430 lines
13 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/remote/remote_ws_client.dart
//
// Patched version with small robustness improvements, extra debug logging,
// and clearer backoff/reconnect behavior. Integrate with RemoteAddedQueue
// via setAddedQueue(...) and call notifyRecoveryCompleted() from your
// SyncManager after recovery sync completes.
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:web_socket_channel/io.dart';
import 'remote_state_store.dart';
import 'remote_added_queue.dart';
typedef WsEventHandler = Future<bool> Function(Map<String, dynamic> msg);
class RemoteWsClient {
RemoteWsClient({
required this.wsUrl,
required this.store,
required this.deduper,
required this.onEvent,
required this.onNeedRecoverySync,
this.autoReconnect = true,
this.reconnectDelay = const Duration(milliseconds: 1500),
this.maxReconnectDelay = const Duration(seconds: 30),
this.dormantWindow = const Duration(minutes: 2), // come sync.js WS_DORMANT_MS
this.pingInterval = const Duration(seconds: 25), // keepalive mobile
this.needFullSyncDelay = const Duration(milliseconds: 800), // come sync.js WS_NEED_FULLSYNC_DELAY_MS
});
final String wsUrl;
final RemoteStateStore store;
final EventRingDeduper deduper;
/// Deve ritornare true SOLO quando levento è applicato con successo
/// (o quando hai schedulato un lavoro "durable" che completerai sicuramente).
final WsEventHandler onEvent;
/// Trigger per fare una sync di recovery (progressive o full)
final VoidCallback onNeedRecoverySync;
final bool autoReconnect;
final Duration reconnectDelay;
final Duration maxReconnectDelay;
final Duration dormantWindow;
final Duration pingInterval;
final Duration needFullSyncDelay;
// --- stato e risorse interne ---
WebSocketChannel? _ch;
StreamSubscription? _sub;
bool _connecting = false;
bool _closing = false;
Timer? _reconnectTimer;
Duration _currentReconnectDelay = Duration.zero;
String? _lastToken;
// optional: queue per batch di "added" (byIds)
RemoteAddedQueue? _addedQueue;
/// Imposta la queue per gestire gli eventi "added" internamente.
void setAddedQueue(RemoteAddedQueue queue) {
_addedQueue = queue;
debugPrint('[remote][ws] addedQueue set');
}
// "last seen" come sync.js (persistito in RemoteStateStore)
int _lastSeenMs = 0;
// stato handshake
bool _authed = false;
// server ha richiesto recovery (need_full_sync=true) e aspetta "recovery_done"
bool _needRecoveryDoneAck = false;
// debounce recovery (evita storm)
Timer? _recoveryDebounce;
bool _recoveryScheduled = false;
bool get isConnected => _ch != null && _authed;
Future<void> connect({required String token}) async {
_lastToken = token;
// evita doppia connect
if (_connecting) return;
if (isConnected) return;
_connecting = true;
_closing = false;
try {
// chiudi connessioni precedenti
await close();
await deduper.init();
final sessionId = await store.getOrCreateSessionId();
// carica lastSeen persistito (se cè)
_lastSeenMs = await store.getWsLastSeenMs();
// se era dormiente > dormantWindow prima ancora di connettere, reset session id (come sync.js)
final now = DateTime.now().millisecondsSinceEpoch;
final dormant = _lastSeenMs != 0 && (now - _lastSeenMs) > dormantWindow.inMilliseconds;
if (dormant) {
debugPrint('[remote][ws] dormant (> ${dormantWindow.inSeconds}s) before connect -> reset session_id');
await store.resetSessionId();
await store.clearWsLastSeenMs();
}
// nuova sessionId dopo reset (se necessario)
final finalSessionId = dormant ? await store.getOrCreateSessionId() : sessionId;
final uri = Uri.parse(wsUrl);
// ✅ IOWebSocketChannel con pingInterval (mobile)
_ch = IOWebSocketChannel.connect(
uri,
pingInterval: pingInterval,
);
_authed = false;
_needRecoveryDoneAck = false;
// reset backoff allapertura
_currentReconnectDelay = reconnectDelay;
_sub = _ch!.stream.listen(
(raw) async {
// --- debug: raw + decoded (pretty) con redaction per token) ---
try {
final rawStr = raw is String ? raw : raw.toString();
debugPrint('[remote][ws][raw] $rawStr');
final decodedForLog = _decode(raw);
if (decodedForLog != null) {
// non modificare il msg originale: crea una copia per il logging
final Map<String, dynamic> copy = Map<String, dynamic>.from(decodedForLog);
if (copy.containsKey('token')) copy['token'] = '<redacted>';
if (copy.containsKey('auth') && copy['auth'] is Map) {
final authMap = Map<String, dynamic>.from(copy['auth'] as Map);
if (authMap.containsKey('token')) authMap['token'] = '<redacted>';
copy['auth'] = authMap;
}
debugPrint('[remote][ws][decoded]\n${const JsonEncoder.withIndent(' ').convert(copy)}');
} else {
debugPrint('[remote][ws][decoded] null (parse failed)');
}
} catch (e, st) {
debugPrint('[remote][ws][debug-print] error=$e\n$st');
}
_touchLastSeen();
final msg = _decode(raw);
if (msg == null) return;
final type = msg['type']?.toString();
// log sintetico dei campi più utili
try {
final eventId = msg['event_id']?.toString();
final id = msg['id']?.toString();
debugPrint('[remote][ws][evt] type=$type event_id=$eventId id=$id');
} catch (_) {}
// auth_error dal server
if (type == 'auth_error') {
debugPrint('[remote][ws] auth_error=${msg['error']}');
_scheduleRecovery();
await close();
return;
}
// auth_ok: se server chiede recovery
if (type == 'auth_ok') {
_authed = true;
final needFull = msg['need_full_sync'] == true;
debugPrint('[remote][ws] auth_ok user=${msg['user']} session=${msg['session_id']} need_full_sync=$needFull');
if (needFull) {
_needRecoveryDoneAck = true;
// come sync.js: recovery ritardata
_scheduleRecovery(delay: needFullSyncDelay);
} else {
// ⭐ PATCH: progressive sync handshake
// notify server that client recovered and is ready for pending events
_send({'type': 'client_recovered'});
}
return;
}
// ping/pong
if (type == 'ping') {
_send({'type': 'pong'});
return;
}
final eventId = msg['event_id']?.toString();
// dedupe -> ACK e stop
if (eventId != null && deduper.has(eventId)) {
_send({'type': 'ack', 'event_id': eventId});
return;
}
// --- gestione rapida "added" tramite queue se presente ---
final msgType = msg['type']?.toString();
if (msgType == 'added') {
final id = msg['id']?.toString();
if (_addedQueue != null) {
try {
_addedQueue!.enqueue(id);
// mark handled locally so we ACK and avoid delega
if (eventId != null) {
await deduper.mark(eventId);
_send({'type': 'ack', 'event_id': eventId});
}
return;
} catch (e, st) {
debugPrint('[remote][ws] addedQueue error=$e\n$st');
// fallthrough: delegate to onEvent as fallback
}
}
}
// Gestione evento: ACK SOLO se handled=true
bool handled = false;
try {
handled = await onEvent(msg);
} catch (e, st) {
debugPrint('[remote][ws] onEvent error=$e\n$st');
handled = false;
// se fallisce, chiedi recovery ma NON ACK
_scheduleRecovery();
}
if (eventId != null && handled) {
await deduper.mark(eventId);
_send({'type': 'ack', 'event_id': eventId});
} else {
// NO ACK: server ritenterà (reliable delivery)
}
},
onDone: () {
debugPrint('[remote][ws] stream closed');
_cleanupChannelOnly();
if (!_closing) {
_scheduleRecovery();
_scheduleReconnect();
}
},
onError: (e) {
debugPrint('[remote][ws] stream error=$e');
_cleanupChannelOnly();
if (!_closing) {
_scheduleRecovery();
_scheduleReconnect();
}
},
cancelOnError: false,
);
// auth handshake
_send({'type': 'auth', 'token': token, 'session_id': finalSessionId});
} finally {
_connecting = false;
}
}
Future<void> close() async {
_closing = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
_recoveryDebounce?.cancel();
_recoveryDebounce = null;
_recoveryScheduled = false;
try {
await _sub?.cancel();
_sub = null;
final ch = _ch;
_ch = null;
_authed = false;
if (ch != null) {
await ch.sink.close();
}
} catch (_) {
// ignore
} finally {
_cleanupAll();
}
}
/// ✅ Da chiamare quando hai completato una recovery richiesta dal server (need_full_sync=true).
/// Invia "recovery_done" una sola volta per sessione (finché non arriva un nuovo need_full_sync).
void notifyRecoveryCompleted() {
if (!_needRecoveryDoneAck) return;
if (!isConnected) return;
_needRecoveryDoneAck = false;
_send({'type': 'recovery_done'});
}
// --------------------------
// reconnect logic (sync.js-like)
// --------------------------
void _scheduleReconnect() {
if (!autoReconnect) return;
// ❗ evita di programmare reconnect se già connesso
if (isConnected) return;
// ❗ evita di programmare reconnect se già in fase di connessione
if (_connecting) return;
// ❗ evita doppio timer
if (_reconnectTimer != null) return;
final token = _lastToken;
if (token == null || token.isEmpty) return;
final delay = _currentReconnectDelay == Duration.zero
? reconnectDelay
: _currentReconnectDelay;
// update backoff for next attempt
_currentReconnectDelay = _nextDelay(delay);
debugPrint('[remote][ws] scheduling reconnect in ${delay.inMilliseconds}ms');
_reconnectTimer = Timer(delay, () async {
_reconnectTimer = null;
// ❗ doppia sicurezza: non riconnettere se già connesso
if (_closing) return;
if (isConnected) return;
if (_connecting) return;
try {
await connect(token: token);
} catch (e, st) {
debugPrint('[remote][ws] reconnect failed: $e\n$st');
_cleanupChannelOnly();
_scheduleReconnect();
}
});
}
Duration _nextDelay(Duration current) {
final nextMs = (current.inMilliseconds * 2).clamp(
reconnectDelay.inMilliseconds,
maxReconnectDelay.inMilliseconds,
);
return Duration(milliseconds: nextMs);
}
// --------------------------
// recovery debounce
// --------------------------
void _scheduleRecovery({Duration delay = const Duration(milliseconds: 250)}) {
if (_recoveryScheduled) return;
_recoveryScheduled = true;
_recoveryDebounce?.cancel();
_recoveryDebounce = Timer(delay, () {
_recoveryScheduled = false;
try {
onNeedRecoverySync();
} catch (e, st) {
debugPrint('[remote][ws] onNeedRecoverySync error: $e\n$st');
}
});
}
// --------------------------
// utils
// --------------------------
void _touchLastSeen() {
_lastSeenMs = DateTime.now().millisecondsSinceEpoch;
unawaited(store.setWsLastSeenMs(_lastSeenMs));
}
void _send(Map<String, dynamic> obj) {
try {
final payload = jsonEncode(obj);
_ch?.sink.add(payload);
debugPrint('[remote][ws] send: ${obj['type'] ?? '<unknown>'}');
} catch (e, st) {
debugPrint('[remote][ws] send failed: $e\n$st');
}
}
Map<String, dynamic>? _decode(dynamic raw) {
try {
final s = raw is String ? raw : raw.toString();
final v = jsonDecode(s);
return v is Map<String, dynamic> ? v : null;
} catch (_) {
return null;
}
}
void _cleanupChannelOnly() {
_sub?.cancel();
_sub = null;
_ch = null;
_authed = false;
_connecting = false;
}
void _cleanupAll() {
_cleanupChannelOnly();
_closing = false;
}
}