496 lines
17 KiB
Dart
496 lines
17 KiB
Dart
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<void>? _setupCompleter;
|
||
final Set<String> _connectedPeers = {};
|
||
String? _sessionId;
|
||
String? _myPeerId;
|
||
bool _isHost = false;
|
||
|
||
// Stream controllers for events
|
||
final _peerConnectedController = StreamController<String>.broadcast();
|
||
final _peerDisconnectedController = StreamController<String>.broadcast();
|
||
final _messageReceivedController = StreamController<SyncMessage>.broadcast();
|
||
final _errorController = StreamController<PeerError>.broadcast();
|
||
final _connectionStateController = StreamController<bool>.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<T>(StreamController<T> controller, T event) {
|
||
if (!controller.isClosed) controller.add(event);
|
||
}
|
||
|
||
/// Stream of peer IDs when a new peer connects
|
||
Stream<String> get onPeerConnected => _peerConnectedController.stream;
|
||
|
||
/// Stream of peer IDs when a peer disconnects
|
||
Stream<String> get onPeerDisconnected => _peerDisconnectedController.stream;
|
||
|
||
/// Stream of sync messages received from peers
|
||
Stream<SyncMessage> get onMessageReceived => _messageReceivedController.stream;
|
||
|
||
/// Stream of errors
|
||
Stream<PeerError> get onError => _errorController.stream;
|
||
|
||
/// Stream of connection state changes (true = connected, false = disconnected)
|
||
Stream<bool> 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<String> 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<WebSocketChannel> _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<Completer<void>> _connectAndAnnounce(String type) async {
|
||
final channel = await _connectToRelay();
|
||
_channel = channel;
|
||
|
||
_listenToChannel(channel);
|
||
startKeepalive();
|
||
|
||
return _announce(type);
|
||
}
|
||
|
||
Completer<void> _announce(String type) {
|
||
final completer = Completer<void>();
|
||
_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<String, dynamic>;
|
||
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<dynamic>?)?.cast<String>() ?? [];
|
||
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<String, dynamic> 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<String> 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<void> 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<void> 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();
|
||
}
|
||
}
|