import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:uuid/uuid.dart'; import '../../utils/future_extensions.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import '../../i18n/strings.g.dart'; import '../../services/base_peer_service.dart'; import '../../utils/app_logger.dart'; import '../models/sync_message.dart'; import '../primitives.dart'; import 'relay_protocol.g.dart'; // Re-export so existing callers that import from here keep working. export '../../services/base_peer_service.dart' show PeerError, PeerErrorType; /// Service for managing Watch Together connections via a WebSocket relay /// /// This service handles: /// - Creating sessions (as host) /// - Joining sessions (as guest) /// - Sending/receiving sync messages through the relay server /// - Reconnection on WebSocket drops class WatchTogetherPeerService with KeepaliveMixin { static const String defaultBaseUrl = 'https://ice.plezy.app'; final String _baseUrl; static String get healthUrl => '$defaultBaseUrl/health'; static String healthUrlFor(String? customBaseUrl) { final base = (customBaseUrl != null && customBaseUrl.trim().isNotEmpty) ? customBaseUrl.trim() : defaultBaseUrl; return '$base/health'; } String get _relayUrl { final wsBase = _baseUrl.replaceFirst(RegExp(r'^https://'), 'wss://').replaceFirst(RegExp(r'^http://'), 'ws://'); return '$wsBase/relay'; } WatchTogetherPeerService({String? customBaseUrl}) : _baseUrl = (customBaseUrl != null && customBaseUrl.trim().isNotEmpty) ? customBaseUrl.trim() : defaultBaseUrl; WebSocketChannel? _channel; StreamSubscription? _channelSubscription; Completer? _setupCompleter; final Set _connectedPeers = {}; String? _sessionId; String? _myPeerId; bool _isHost = false; // Stream controllers for events final _peerConnectedController = StreamController.broadcast(); final _peerDisconnectedController = StreamController.broadcast(); final _messageReceivedController = StreamController.broadcast(); final _errorController = StreamController.broadcast(); final _connectionStateController = StreamController.broadcast(); // Reconnection state int _reconnectAttempts = 0; static const int _maxReconnectAttempts = 3; Timer? _reconnectTimer; /// Called after a successful reconnection so the provider can re-announce join. void Function()? onReconnected; // Keepalive (via KeepaliveMixin) @override Duration get pingInterval => const Duration(seconds: 15); @override Duration get pongTimeout => const Duration(seconds: 30); void _safeAdd(StreamController controller, T event) { if (!controller.isClosed) controller.add(event); } /// Stream of peer IDs when a new peer connects Stream get onPeerConnected => _peerConnectedController.stream; /// Stream of peer IDs when a peer disconnects Stream get onPeerDisconnected => _peerDisconnectedController.stream; /// Stream of sync messages received from peers Stream get onMessageReceived => _messageReceivedController.stream; /// Stream of errors Stream get onError => _errorController.stream; /// Stream of connection state changes (true = connected, false = disconnected) Stream get onConnectionStateChanged => _connectionStateController.stream; /// Current session ID (null if not in a session) String? get sessionId => _sessionId; /// This peer's ID String? get myPeerId => _myPeerId; /// Whether this peer is the host bool get isHost => _isHost; /// Whether currently connected to a session bool get isConnected => _channel != null && _connectedPeers.isNotEmpty; /// List of connected peer IDs List get connectedPeers => _connectedPeers.toList(); /// Generate a short, readable session ID (5 alphanumeric chars) static String _generateSessionId() { const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; final random = Random.secure(); return String.fromCharCodes(List.generate(5, (_) => chars.codeUnitAt(random.nextInt(chars.length)))); } /// Connect to the relay WebSocket and set up the message listener. /// Returns a completer that completes when the expected response arrives. Future _connectToRelay() async { final uri = Uri.parse(_relayUrl); final channel = WebSocketChannel.connect(uri); // Wait for the connection to be established await channel.ready; return channel; } /// Connect, listen, and send a room setup announcement. Future> _connectAndAnnounce(String type) async { final channel = await _connectToRelay(); _channel = channel; _listenToChannel(channel); startKeepalive(); return _announce(type); } Completer _announce(String type) { final completer = Completer(); _setupCompleter = completer; _sendRaw({'type': type, 'sessionId': _sessionId, 'peerId': _myPeerId}); return completer; } /// Listen on the channel stream and route incoming server messages. void _listenToChannel(WebSocketChannel channel) { _channelSubscription?.cancel(); _channelSubscription = channel.stream.listen( (data) { if (!identical(_channel, channel)) return; resetPongTimer(); _handleServerMessage(data as String); }, onError: (error) { if (!identical(_channel, channel)) return; appLogger.e('WatchTogether: WebSocket error', error: error); _safeAdd( _errorController, PeerError(type: PeerErrorType.serverError, message: 'WebSocket error: $error', originalError: error), ); if (_setupCompleter case final completer? when !completer.isCompleted) { completer.completeError(error); _setupCompleter = null; } _handleWebSocketClosed(); }, onDone: () { if (!identical(_channel, channel)) return; appLogger.w('WatchTogether: WebSocket closed'); if (_setupCompleter case final completer? when !completer.isCompleted) { completer.completeError( const PeerError(type: PeerErrorType.connectionFailed, message: 'WebSocket closed before setup completed'), ); _setupCompleter = null; } _handleWebSocketClosed(); }, ); } /// Handle an incoming server message (JSON string). void _handleServerMessage(String raw) { try { final msg = jsonDecode(raw) as Map; final type = msg['type'] as String?; switch (type) { case RelayProtocol.created: appLogger.d('WatchTogether: Room created: ${msg['sessionId']}'); _safeAdd(_connectionStateController, true); if (_setupCompleter case final completer? when !completer.isCompleted) { completer.complete(); _setupCompleter = null; } case RelayProtocol.joined: final peers = (msg['peers'] as List?)?.cast() ?? []; appLogger.d('WatchTogether: Joined room ${msg['sessionId']} with peers: $peers'); for (final peerId in peers) { _connectedPeers.add(peerId); _safeAdd(_peerConnectedController, peerId); } _safeAdd(_connectionStateController, true); if (_setupCompleter case final completer? when !completer.isCompleted) { completer.complete(); _setupCompleter = null; } case RelayProtocol.peerJoined: final peerId = msg['peerId'] as String; appLogger.d('WatchTogether: Peer joined: $peerId'); _connectedPeers.add(peerId); _safeAdd(_peerConnectedController, peerId); _safeAdd(_connectionStateController, true); case RelayProtocol.peerLeft: final peerId = msg['peerId'] as String; appLogger.d('WatchTogether: Peer left: $peerId'); _connectedPeers.remove(peerId); _safeAdd(_peerDisconnectedController, peerId); if (_connectedPeers.isEmpty) { _safeAdd(_connectionStateController, false); } case RelayProtocol.message: final payload = msg['payload']; final serverFrom = msg['from'] as String?; if (payload != null) { try { final payloadStr = payload is String ? payload : jsonEncode(payload); var syncMsg = SyncMessage.fromJson(payloadStr); // Use the server-authenticated sender ID instead of the // self-reported peerId in the payload to prevent spoofing. if (serverFrom != null && syncMsg.peerId != serverFrom) { syncMsg = syncMsg.copyWith(peerId: serverFrom); } _safeAdd(_messageReceivedController, syncMsg); } catch (e) { appLogger.e('WatchTogether: Failed to parse sync message payload', error: e); } } case RelayProtocol.error: final code = msg['code'] as String? ?? 'unknown'; final message = msg['message'] as String? ?? t.common.unknown; appLogger.e('WatchTogether: Server error: $code - $message'); final error = PeerError(type: PeerErrorType.serverError, message: '$code: $message', serverCode: code); _safeAdd(_errorController, error); if (_setupCompleter case final completer? when !completer.isCompleted) { completer.completeError(error); _setupCompleter = null; } case RelayProtocol.pong: // Handled by resetPongTimer() already break; default: appLogger.w('WatchTogether: Unknown server message type: $type'); } } catch (e) { appLogger.e('WatchTogether: Failed to parse server message', error: e); } } @override void sendPing() => _sendRaw({'type': RelayProtocol.ping}); @override void onPongTimeout() { appLogger.w('WatchTogether: Pong timeout — closing WebSocket'); try { _channel?.sink.close(); } catch (e) { appLogger.d('WatchTogether: pong-timeout close ignored', error: e); } } /// Send a raw JSON map to the relay. void _sendRaw(Map msg) { try { _channel?.sink.add(jsonEncode(msg)); } catch (e) { appLogger.e('WatchTogether: Failed to send message', error: e); } } /// Handle the WebSocket being closed unexpectedly — attempt reconnection. void _handleWebSocketClosed() { stopKeepalive(); _channelSubscription?.cancel(); _channelSubscription = null; _channel = null; // Notify peers lost for (final peerId in _connectedPeers.toList()) { _safeAdd(_peerDisconnectedController, peerId); } _connectedPeers.clear(); _safeAdd(_connectionStateController, false); // Attempt to reconnect if we had a session if (_sessionId != null) { _attemptReconnect(); } } /// Attempt to reconnect to the relay and re-join/re-create the room. void _attemptReconnect() { if (_reconnectAttempts >= _maxReconnectAttempts) { appLogger.e('WatchTogether: Max reconnect attempts reached'); _safeAdd( _errorController, const PeerError( type: PeerErrorType.connectionFailed, message: 'Lost connection to relay after multiple reconnect attempts', ), ); return; } _reconnectAttempts++; final delay = Duration(seconds: _reconnectAttempts * 2); appLogger.d('WatchTogether: Reconnect attempt $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s'); _reconnectTimer?.cancel(); _reconnectTimer = Timer(delay, () async { try { // Always try join first — the room may still have peers (e.g. host // reconnecting while guests remain). Fall back to create only if // the room no longer exists and we were the host. final completer = await _connectAndAnnounce(RelayProtocol.join); try { await completer.future.namedTimeout(const Duration(seconds: 10), operation: 'WatchTogether reconnect'); } on PeerError catch (e) { if (_isHost && e.serverCode == RelayProtocol.roomNotFoundCode) { appLogger.d('WatchTogether: Room gone, re-creating as host'); final createCompleter = _announce(RelayProtocol.create); await createCompleter.future.namedTimeout( const Duration(seconds: 10), operation: 'WatchTogether reconnect create', ); } else { rethrow; } } _reconnectAttempts = 0; appLogger.d('WatchTogether: Reconnected successfully'); try { onReconnected?.call(); } catch (e) { appLogger.e('WatchTogether: Reconnect callback failed', error: e); } } catch (e) { appLogger.e('WatchTogether: Reconnect failed', error: e); _handleWebSocketClosed(); } }); } /// Create a new session as host /// /// Returns the session ID that others can use to join. /// If [sessionId] is provided, uses that instead of generating a new one. Future createSession({String? sessionId}) async { if (_channel != null) { await disconnect(); } final resolvedSessionId = sessionId?.toUpperCase() ?? _generateSessionId(); if (!RelayProtocol.isValidSessionId(resolvedSessionId)) { throw ArgumentError.value( sessionId, 'sessionId', 'Must be 1–${RelayProtocol.maxSessionIdLength} letters, digits, _ or -', ); } _isHost = true; _sessionId = resolvedSessionId; _myPeerId = watchTogetherHostPeerId(resolvedSessionId); _reconnectAttempts = 0; try { final completer = await _connectAndAnnounce(RelayProtocol.create); await completer.future.timeout( const Duration(seconds: 10), onTimeout: () { throw const PeerError(type: PeerErrorType.timeout, message: 'Timed out creating session'); }, ); appLogger.d('WatchTogether: Session created: $_sessionId'); return _sessionId!; } catch (e) { appLogger.e('WatchTogether: Failed to create session', error: e); await disconnect(); rethrow; } } /// Join an existing session as guest. Future joinSession(String sessionId) async { if (_channel != null) { await disconnect(); } final resolvedSessionId = sessionId.toUpperCase(); if (!RelayProtocol.isValidSessionId(resolvedSessionId)) { throw ArgumentError.value( sessionId, 'sessionId', 'Must be 1–${RelayProtocol.maxSessionIdLength} letters, digits, _ or -', ); } _isHost = false; _sessionId = resolvedSessionId; _myPeerId = const Uuid().v4(); _reconnectAttempts = 0; try { final completer = await _connectAndAnnounce(RelayProtocol.join); await completer.future.timeout( const Duration(seconds: 10), onTimeout: () { throw PeerError(type: PeerErrorType.timeout, message: t.watchTogether.failedToJoin); }, ); appLogger.d('WatchTogether: Joined session: $_sessionId'); } catch (e) { appLogger.e('WatchTogether: Failed to join session', error: e); await disconnect(); rethrow; } } /// Broadcast a message to all connected peers void broadcast(SyncMessage message) { final payload = message.toJson(); _sendRaw({'type': RelayProtocol.broadcast, 'payload': payload}); } /// Send a message to a specific peer void sendTo(String peerId, SyncMessage message) { if (!RelayProtocol.isValidPeerId(peerId)) { throw ArgumentError.value(peerId, 'peerId', 'Must be 1–${RelayProtocol.maxPeerIdLength} letters, digits, _ or -'); } final payload = message.toJson(); _sendRaw({'type': RelayProtocol.sendTo, 'to': peerId, 'payload': payload}); } /// Disconnect from all peers and close the session Future disconnect() async { appLogger.d('WatchTogether: Disconnecting...'); _reconnectTimer?.cancel(); _reconnectTimer = null; stopKeepalive(); unawaited(_channelSubscription?.cancel()); _channelSubscription = null; try { await _channel?.sink.close(); } catch (e) { appLogger.d('WatchTogether: channel close ignored', error: e); } _channel = null; _setupCompleter = null; _connectedPeers.clear(); _sessionId = null; _myPeerId = null; _isHost = false; _reconnectAttempts = 0; _safeAdd(_connectionStateController, false); } /// Dispose all resources void dispose() { disconnect(); _peerConnectedController.close(); _peerDisconnectedController.close(); _messageReceivedController.close(); _errorController.close(); _connectionStateController.close(); } }