Add Companion Remote for mobile-to-desktop control
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:peerdart/peerdart.dart';
|
||||
|
||||
import '../../models/companion_remote/remote_command.dart';
|
||||
import '../../models/companion_remote/remote_command_type.dart';
|
||||
import '../../models/companion_remote/remote_session.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
|
||||
enum RemotePeerErrorType {
|
||||
connectionFailed,
|
||||
peerDisconnected,
|
||||
dataChannelError,
|
||||
serverError,
|
||||
timeout,
|
||||
invalidSession,
|
||||
unknown,
|
||||
}
|
||||
|
||||
class RemotePeerError {
|
||||
final RemotePeerErrorType type;
|
||||
final String message;
|
||||
final dynamic originalError;
|
||||
|
||||
const RemotePeerError({
|
||||
required this.type,
|
||||
required this.message,
|
||||
this.originalError,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() => 'RemotePeerError($type): $message';
|
||||
}
|
||||
|
||||
class CompanionRemotePeerService {
|
||||
Peer? _peer;
|
||||
DataConnection? _connection;
|
||||
String? _sessionId;
|
||||
String? _pin;
|
||||
String? _myPeerId;
|
||||
RemoteSessionRole? _role;
|
||||
|
||||
final _commandReceivedController = StreamController<RemoteCommand>.broadcast();
|
||||
final _deviceConnectedController = StreamController<RemoteDevice>.broadcast();
|
||||
final _deviceDisconnectedController = StreamController<void>.broadcast();
|
||||
final _errorController = StreamController<RemotePeerError>.broadcast();
|
||||
final _connectionStateController = StreamController<RemoteSessionStatus>.broadcast();
|
||||
|
||||
int _reconnectAttempts = 0;
|
||||
static const int _maxReconnectAttempts = 3;
|
||||
Timer? _reconnectTimer;
|
||||
Timer? _pingTimer;
|
||||
|
||||
Stream<RemoteCommand> get onCommandReceived => _commandReceivedController.stream;
|
||||
Stream<RemoteDevice> get onDeviceConnected => _deviceConnectedController.stream;
|
||||
Stream<void> get onDeviceDisconnected => _deviceDisconnectedController.stream;
|
||||
Stream<RemotePeerError> get onError => _errorController.stream;
|
||||
Stream<RemoteSessionStatus> get onConnectionStateChanged => _connectionStateController.stream;
|
||||
|
||||
String? get sessionId => _sessionId;
|
||||
String? get pin => _pin;
|
||||
String? get myPeerId => _myPeerId;
|
||||
RemoteSessionRole? get role => _role;
|
||||
bool get isHost => _role == RemoteSessionRole.host;
|
||||
bool get isConnected => _connection != null;
|
||||
|
||||
String _generateSessionId() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
final random = Random.secure();
|
||||
return List.generate(8, (index) => chars[random.nextInt(chars.length)]).join();
|
||||
}
|
||||
|
||||
String _generatePin() {
|
||||
final random = Random.secure();
|
||||
return List.generate(6, (index) => random.nextInt(10).toString()).join();
|
||||
}
|
||||
|
||||
void _attachCommonPeerListeners({
|
||||
required Completer completer,
|
||||
required RemotePeerErrorType errorType,
|
||||
required String errorMessage,
|
||||
}) {
|
||||
_peer!.on('disconnected').listen((_) {
|
||||
appLogger.w('CompanionRemote: Peer disconnected from server');
|
||||
_handleDisconnectedFromServer();
|
||||
});
|
||||
|
||||
_peer!.on('close').listen((_) {
|
||||
appLogger.d('CompanionRemote: Peer closed');
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
});
|
||||
|
||||
_peer!.on('error').listen((error) {
|
||||
appLogger.e('CompanionRemote: Peer error', error: error);
|
||||
_errorController.add(
|
||||
RemotePeerError(
|
||||
type: errorType,
|
||||
message: '$errorMessage: $error',
|
||||
originalError: error,
|
||||
),
|
||||
);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<({String sessionId, String pin})> createSession(String deviceName, String platform) async {
|
||||
if (_peer != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
_role = RemoteSessionRole.host;
|
||||
_sessionId = _generateSessionId();
|
||||
_pin = _generatePin();
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
final completer = Completer<({String sessionId, String pin})>();
|
||||
|
||||
try {
|
||||
_peer = Peer(id: 'cr-$_sessionId-$_pin');
|
||||
|
||||
_peer!.on('open').listen((id) {
|
||||
_myPeerId = id as String;
|
||||
appLogger.d('CompanionRemote: Host peer opened with ID: $_myPeerId');
|
||||
_connectionStateController.add(RemoteSessionStatus.connected);
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete((sessionId: _sessionId!, pin: _pin!));
|
||||
}
|
||||
});
|
||||
|
||||
_peer!.on('connection').listen((conn) {
|
||||
final dataConn = conn as DataConnection;
|
||||
_handleNewConnection(dataConn, deviceName, platform);
|
||||
});
|
||||
|
||||
_attachCommonPeerListeners(
|
||||
completer: completer,
|
||||
errorType: RemotePeerErrorType.serverError,
|
||||
errorMessage: 'Server error',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to create peer', error: e);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw RemotePeerError(
|
||||
type: RemotePeerErrorType.timeout,
|
||||
message: 'Timed out creating session',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> joinSession(
|
||||
String sessionId,
|
||||
String pin,
|
||||
String deviceName,
|
||||
String platform,
|
||||
) async {
|
||||
if (_peer != null) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
_role = RemoteSessionRole.remote;
|
||||
_sessionId = sessionId.toUpperCase();
|
||||
_pin = pin;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
final completer = Completer<void>();
|
||||
|
||||
try {
|
||||
_peer = Peer();
|
||||
|
||||
_peer!.on('open').listen((id) {
|
||||
_myPeerId = id as String;
|
||||
appLogger.d('CompanionRemote: Remote peer opened with ID: $_myPeerId');
|
||||
|
||||
final hostPeerId = 'cr-$_sessionId-$_pin';
|
||||
appLogger.d('CompanionRemote: Connecting to host: $hostPeerId');
|
||||
|
||||
_connectionStateController.add(RemoteSessionStatus.connecting);
|
||||
|
||||
final conn = _peer!.connect(hostPeerId, options: PeerConnectOption(reliable: true));
|
||||
_handleNewConnection(conn, deviceName, platform, isOutgoing: true, completer: completer);
|
||||
});
|
||||
|
||||
_attachCommonPeerListeners(
|
||||
completer: completer,
|
||||
errorType: RemotePeerErrorType.connectionFailed,
|
||||
errorMessage: 'Failed to connect to session',
|
||||
);
|
||||
} catch (e) {
|
||||
appLogger.e('CompanionRemote: Failed to create peer for joining', error: e);
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(e);
|
||||
}
|
||||
}
|
||||
|
||||
return completer.future.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () {
|
||||
throw RemotePeerError(
|
||||
type: RemotePeerErrorType.timeout,
|
||||
message: 'Timed out joining session',
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleNewConnection(
|
||||
DataConnection conn,
|
||||
String deviceName,
|
||||
String platform, {
|
||||
bool isOutgoing = false,
|
||||
Completer<void>? completer,
|
||||
}) {
|
||||
final peerId = conn.peer;
|
||||
appLogger.d('CompanionRemote: New connection ${isOutgoing ? "to" : "from"}: $peerId');
|
||||
|
||||
conn.on('open').listen((_) {
|
||||
appLogger.d('CompanionRemote: Data channel opened with: $peerId');
|
||||
_connection = conn;
|
||||
_connectionStateController.add(RemoteSessionStatus.connected);
|
||||
|
||||
final device = RemoteDevice(
|
||||
id: peerId,
|
||||
name: deviceName,
|
||||
platform: platform,
|
||||
);
|
||||
|
||||
_deviceConnectedController.add(device);
|
||||
|
||||
_startPingTimer();
|
||||
|
||||
sendDeviceInfo(deviceName, platform);
|
||||
|
||||
if (completer != null && !completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
});
|
||||
|
||||
conn.on('data').listen((data) {
|
||||
print('🟡 PeerService: DATA RECEIVED! data type: ${data.runtimeType}');
|
||||
try {
|
||||
print('🟡 Parsing as JSON...');
|
||||
final json = data as Map<String, dynamic>;
|
||||
print('🟡 Creating RemoteCommand from JSON...');
|
||||
final command = RemoteCommand.fromJson(json);
|
||||
print('🟡 Command received: ${command.type} from $peerId');
|
||||
appLogger.d('CompanionRemote: Received command: ${command.type} from $peerId');
|
||||
|
||||
// Send acknowledgment for non-ping/pong/ack commands
|
||||
if (command.type != RemoteCommandType.ping &&
|
||||
command.type != RemoteCommandType.pong &&
|
||||
command.type != RemoteCommandType.ack &&
|
||||
command.type != RemoteCommandType.deviceInfo) {
|
||||
print('🟡 Sending ACK for ${command.type}...');
|
||||
final ackCommand = RemoteCommand(
|
||||
type: RemoteCommandType.ack,
|
||||
deviceId: _myPeerId ?? 'unknown',
|
||||
deviceName: deviceName,
|
||||
data: {'originalCommand': command.type.toString()},
|
||||
);
|
||||
_connection?.send(ackCommand.toJson());
|
||||
print('🟡 ACK sent!');
|
||||
}
|
||||
|
||||
print('🟡 Adding to _commandReceivedController...');
|
||||
_commandReceivedController.add(command);
|
||||
print('🟡 Command added to stream!');
|
||||
|
||||
if (command.type == RemoteCommandType.ping) {
|
||||
print('🟡 Responding to ping with pong...');
|
||||
_sendPong(deviceName, platform);
|
||||
} else if (command.type == RemoteCommandType.ack) {
|
||||
print('✅ RECEIVED ACK from desktop for: ${json['data']?['originalCommand']}');
|
||||
}
|
||||
} catch (e) {
|
||||
print('🔴 PeerService: Failed to parse command: $e');
|
||||
appLogger.e('CompanionRemote: Failed to parse command', error: e);
|
||||
}
|
||||
});
|
||||
|
||||
conn.on('close').listen((_) {
|
||||
appLogger.d('CompanionRemote: Connection closed with: $peerId');
|
||||
_connection = null;
|
||||
_deviceDisconnectedController.add(null);
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
_stopPingTimer();
|
||||
});
|
||||
|
||||
conn.on('error').listen((error) {
|
||||
appLogger.e('CompanionRemote: Connection error with $peerId', error: error);
|
||||
_errorController.add(
|
||||
RemotePeerError(
|
||||
type: RemotePeerErrorType.dataChannelError,
|
||||
message: 'Connection error with peer: $error',
|
||||
originalError: error,
|
||||
),
|
||||
);
|
||||
_connectionStateController.add(RemoteSessionStatus.error);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleDisconnectedFromServer() {
|
||||
if (_reconnectAttempts < _maxReconnectAttempts) {
|
||||
_reconnectAttempts++;
|
||||
final delay = Duration(seconds: _reconnectAttempts * 2);
|
||||
|
||||
appLogger.d(
|
||||
'CompanionRemote: Attempting reconnect $_reconnectAttempts/$_maxReconnectAttempts in ${delay.inSeconds}s',
|
||||
);
|
||||
|
||||
_reconnectTimer?.cancel();
|
||||
_reconnectTimer = Timer(delay, () {
|
||||
_peer?.reconnect();
|
||||
});
|
||||
} else {
|
||||
appLogger.e('CompanionRemote: Max reconnect attempts reached');
|
||||
_errorController.add(
|
||||
const RemotePeerError(
|
||||
type: RemotePeerErrorType.connectionFailed,
|
||||
message: 'Lost connection to server after multiple reconnect attempts',
|
||||
),
|
||||
);
|
||||
_connectionStateController.add(RemoteSessionStatus.error);
|
||||
}
|
||||
}
|
||||
|
||||
void _startPingTimer() {
|
||||
_stopPingTimer();
|
||||
_pingTimer = Timer.periodic(const Duration(seconds: 5), (_) {
|
||||
if (_connection != null) {
|
||||
sendCommand(RemoteCommand(
|
||||
type: RemoteCommandType.ping,
|
||||
deviceId: _myPeerId ?? 'unknown',
|
||||
deviceName: 'local',
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _stopPingTimer() {
|
||||
_pingTimer?.cancel();
|
||||
_pingTimer = null;
|
||||
}
|
||||
|
||||
void _sendPong(String deviceName, String platform) {
|
||||
sendCommand(RemoteCommand(
|
||||
type: RemoteCommandType.pong,
|
||||
deviceId: _myPeerId ?? 'unknown',
|
||||
deviceName: deviceName,
|
||||
data: {'platform': platform},
|
||||
));
|
||||
}
|
||||
|
||||
void sendDeviceInfo(String deviceName, String platform) {
|
||||
sendCommand(RemoteCommand(
|
||||
type: RemoteCommandType.deviceInfo,
|
||||
deviceId: _myPeerId ?? 'unknown',
|
||||
deviceName: deviceName,
|
||||
data: {
|
||||
'platform': platform,
|
||||
'role': _role?.name,
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
void sendCommand(RemoteCommand command) {
|
||||
print('🔵 PeerService sendCommand called! command.type: ${command.type}');
|
||||
print('🔵 _connection: $_connection');
|
||||
|
||||
if (_connection == null) {
|
||||
print('🔴 PeerService: No connection!');
|
||||
appLogger.w('CompanionRemote: No connection to send command');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
print('🔵 Converting command to JSON...');
|
||||
final json = command.toJson();
|
||||
print('🔵 Sending via connection.send...');
|
||||
_connection!.send(json);
|
||||
print('🔵 Command sent over wire!');
|
||||
appLogger.d('CompanionRemote: Sent command: ${command.type}');
|
||||
} catch (e) {
|
||||
print('🔴 PeerService send FAILED: $e');
|
||||
appLogger.e('CompanionRemote: Failed to send command', error: e);
|
||||
_errorController.add(
|
||||
RemotePeerError(
|
||||
type: RemotePeerErrorType.dataChannelError,
|
||||
message: 'Failed to send command: $e',
|
||||
originalError: e,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
appLogger.d('CompanionRemote: Disconnecting');
|
||||
|
||||
_stopPingTimer();
|
||||
_reconnectTimer?.cancel();
|
||||
|
||||
_connection?.close();
|
||||
_connection = null;
|
||||
|
||||
_peer?.dispose();
|
||||
_peer = null;
|
||||
|
||||
_sessionId = null;
|
||||
_pin = null;
|
||||
_myPeerId = null;
|
||||
_role = null;
|
||||
_reconnectAttempts = 0;
|
||||
|
||||
_connectionStateController.add(RemoteSessionStatus.disconnected);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
disconnect();
|
||||
_commandReceivedController.close();
|
||||
_deviceConnectedController.close();
|
||||
_deviceDisconnectedController.close();
|
||||
_errorController.close();
|
||||
_connectionStateController.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user