Files
xianyan/lib/features/file_transfer/services/signaling_service.dart
Developer 83720002e6 feat: 新增工作台模式、系统托盘,修复多平台兼容性问题
1. 新增工作台三栏布局模式,适配宽屏设备
2. 添加跨平台系统托盘支持,新增托盘图标资源
3. 修复工作台模式下导航返回异常问题
4. 统一JSON类型安全解析,替换硬类型转换
5. 增加macOS深度链接支持,统一渠道分发信息
6. 优化部分页面生命周期和状态加载逻辑
7. 移除废弃的nearby_connections依赖
2026-06-19 06:43:55 +08:00

999 lines
31 KiB
Dart

// ============================================================
// 闲言APP — WebSocket信令+消息中转服务
// 创建时间: 2026-05-09
// 更新时间: 2026-06-19
// 作用: WebRTC信令中转+文本消息互发+设备发现+协议协商+WebSocket中转
// 参考 SnapDrop WebSocket 中转 + LocalSend Signaling 协议
// 上次更新: 类型安全修复(int vs num): 时间戳使用 SafeJson.parseInt
// ============================================================
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:web_socket_channel/web_socket_channel.dart';
import 'package:xianyan/core/utils/safe_json.dart';
import 'package:xianyan/core/utils/logger.dart';
import 'package:xianyan/features/file_transfer/services/transfer_api_service.dart';
import '../models/transfer_enums.dart';
import '../models/transfer_device.dart';
enum SignalingMessageType {
register('register'),
discover('discover'),
discoverResponse('discover_response'),
offer('offer'),
answer('answer'),
iceCandidate('ice-candidate'),
textMessage('text-message'),
fileMeta('file-meta'),
fileChunk('file-chunk'),
fileComplete('file-complete'),
progress('progress'),
heartbeat('heartbeat'),
heartbeatAck('heartbeat_ack'),
leave('leave'),
clipboardSync('clipboard-sync'),
pairRequest('pair-request'),
pairResponse('pair-response'),
discoverMyDevices('discoverMyDevices'),
myDevicesResponse('myDevicesResponse'),
transportNegotiate('transportNegotiate'),
transportNegotiateResponse('transportNegotiateResponse'),
wsRelay('wsRelay'),
ping('ping'),
pong('pong'),
displayName('display-name'),
peers('peers'),
registered('registered'),
peerJoined('peer-joined'),
peerLeft('peer-left'),
disconnect('disconnect'),
error('error'),
deliveryAck('delivery-ack'),
chunkAck('chunk-ack'),
resumeRequest('resume-request'),
voiceMeta('voice-meta'),
cloudCacheNotify('cloud-cache-notify'),
keyExchange('key-exchange'),
canvasStroke('canvas-stroke'),
canvasCursor('canvas-cursor'),
canvasJoin('canvas-join'),
canvasLeave('canvas-leave'),
canvasSnapshot('canvas-snapshot'),
screenShareOffer('screen-share-offer'),
screenShareAnswer('screen-share-answer'),
screenShareStop('screen-share-stop'),
screenShareIceCandidate('screen-share-ice-candidate'),
screenShareFrame('screen-share-frame'),
screenShareRequest('screen-share-request'),
screenShareAccept('screen-share-accept'),
screenShareReject('screen-share-reject'),
pairingCodeCreate('pairing-code-create'),
pairingCodeCreated('pairing-code-created'),
pairingCodeJoin('pairing-code-join'),
pairingMatched('pairing-matched'),
radarBroadcast('radar-broadcast'),
radarScan('radar-scan'),
radarDevices('radar-devices'),
remoteInput('remote-input'),
deviceOnline('deviceOnline'),
deviceOffline('deviceOffline'),
unknown('unknown');
const SignalingMessageType(this.id);
final String id;
static SignalingMessageType fromId(String id) {
return SignalingMessageType.values.firstWhere(
(t) => t.id == id,
orElse: () => SignalingMessageType.unknown,
);
}
}
class SignalingMessage {
const SignalingMessage({
required this.type,
required this.from,
this.to,
this.payload,
this.timestamp,
});
final SignalingMessageType type;
final String from;
final String? to;
final Map<String, dynamic>? payload;
final DateTime? timestamp;
Map<String, dynamic> toJson() {
return {
'type': type.id,
'from': from,
if (to != null) 'to': to,
if (payload != null) 'payload': payload,
'ts': (timestamp ?? DateTime.now()).millisecondsSinceEpoch,
};
}
factory SignalingMessage.fromJson(Map<String, dynamic> json) {
var payload = json['payload'] as Map<String, dynamic>?;
if (json['relayType'] != null) {
payload ??= {};
payload['relayType'] ??= json['relayType'];
}
return SignalingMessage(
type: SignalingMessageType.fromId(json['type'] as String? ?? ''),
from: (json['from'] as String? ?? '').isNotEmpty
? json['from'] as String
: json['sender'] as String? ?? '',
to: json['to'] as String?,
payload: payload,
timestamp: json['ts'] != null
? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['ts']))
: null,
);
}
}
class DeviceOnlineEvent {
const DeviceOnlineEvent({
required this.deviceId,
required this.isOnline,
this.device,
this.timestamp,
});
final String deviceId;
final bool isOnline;
final TransferDevice? device;
final DateTime? timestamp;
factory DeviceOnlineEvent.fromJson(Map<String, dynamic> json) {
return DeviceOnlineEvent(
deviceId: json['deviceId'] as String? ?? json['from'] as String? ?? '',
isOnline: json['isOnline'] as bool? ?? true,
device: json['device'] != null
? TransferDevice.fromSignaling(json['device'] as Map<String, dynamic>)
: null,
timestamp: json['ts'] != null
? DateTime.fromMillisecondsSinceEpoch(SafeJson.parseInt(json['ts']))
: null,
);
}
}
class SignalingService {
SignalingService({
this.serverUrl = 'wss://tools.wktyl.com:9443',
this.heartbeatInterval = const Duration(seconds: 30),
this.baseReconnectDelay = const Duration(seconds: 1),
this.maxReconnectAttempts = 10,
TransferApiService? apiService,
}) : _apiService = apiService ?? TransferApiService();
String serverUrl;
final Duration heartbeatInterval;
final Duration baseReconnectDelay;
final int maxReconnectAttempts;
final TransferApiService _apiService;
WebSocketChannel? _channel;
bool _isConnected = false;
bool get isConnected => _isConnected;
Timer? _heartbeatTimer;
Timer? _reconnectTimer;
int _reconnectAttempts = 0;
final _random = Random();
String? _deviceId;
String? _alias;
String? _fingerprint;
String? _userId;
String? _ipCity;
String? _ipRange;
String? _deviceModel;
String? get deviceId => _deviceId;
String? get userId => _userId;
DateTime? _lastHeartbeatAck;
DateTime? get lastHeartbeatAck => _lastHeartbeatAck;
String? _serverDisplayName;
String? get serverDisplayName => _serverDisplayName;
final StreamController<SignalingMessage> _messageController =
StreamController<SignalingMessage>.broadcast();
Stream<SignalingMessage> get onMessage => _messageController.stream;
final StreamController<bool> _connectionController =
StreamController<bool>.broadcast();
Stream<bool> get onConnectionChanged => _connectionController.stream;
final StreamController<List<TransferDevice>> _onlineDevicesController =
StreamController<List<TransferDevice>>.broadcast();
Stream<List<TransferDevice>> get onOnlineDevicesChanged =>
_onlineDevicesController.stream;
final StreamController<List<TransferDevice>> _myDevicesController =
StreamController<List<TransferDevice>>.broadcast();
Stream<List<TransferDevice>> get onMyDevicesChanged =>
_myDevicesController.stream;
final StreamController<DeviceOnlineEvent> _deviceOnlineController =
StreamController<DeviceOnlineEvent>.broadcast();
Stream<DeviceOnlineEvent> get onDeviceOnlineChanged =>
_deviceOnlineController.stream;
final StreamController<Map<String, dynamic>> _pairingCodeController =
StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get onPairingCodeEvent =>
_pairingCodeController.stream;
final StreamController<List<Map<String, dynamic>>> _radarDevicesController =
StreamController<List<Map<String, dynamic>>>.broadcast();
Stream<List<Map<String, dynamic>>> get onRadarDevicesChanged =>
_radarDevicesController.stream;
final StreamController<Map<String, dynamic>> _screenShareRequestController =
StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get onScreenShareRequest =>
_screenShareRequestController.stream;
final StreamController<Map<String, dynamic>> _screenShareFrameController =
StreamController<Map<String, dynamic>>.broadcast();
Stream<Map<String, dynamic>> get onScreenShareFrame =>
_screenShareFrameController.stream;
Future<void> connect(
String deviceId, {
String? alias,
String? fingerprint,
String? userId,
String? ipCity,
String? ipRange,
String? deviceModel,
}) async {
_deviceId = deviceId;
_alias = alias;
_fingerprint = fingerprint;
_userId = userId;
_ipCity = ipCity;
_ipRange = ipRange;
_deviceModel = deviceModel;
try {
Log.i('Signaling: Connecting to $serverUrl...');
try {
final info = await _apiService.getSignalingInfo();
if (info.signalingUrl.isNotEmpty) {
serverUrl = info.signalingUrl;
Log.i('Signaling: Using server-provided URL: $serverUrl');
}
} catch (e) {
Log.w('Signaling: Failed to get signaling info, using default: $e');
}
_channel = WebSocketChannel.connect(Uri.parse(serverUrl));
await _channel!.ready.timeout(
const Duration(seconds: 6),
onTimeout: () {
throw TimeoutException('Signaling connection timed out');
},
);
_channel!.stream.listen(
(data) {
_handleMessage(data as String);
},
onDone: () {
_isConnected = false;
_connectionController.add(false);
Log.w('Signaling: Connection closed');
_scheduleReconnect();
},
onError: (Object error) {
_isConnected = false;
_connectionController.add(false);
Log.e('Signaling: Connection error: $error');
_scheduleReconnect();
},
);
_isConnected = true;
_reconnectAttempts = 0;
_connectionController.add(true);
_startHeartbeat();
final registerMsg = SignalingMessage(
type: SignalingMessageType.register,
from: deviceId,
payload: {
'alias': alias ?? '闲言设备',
'fingerprint': fingerprint ?? '',
'deviceType': 'mobile',
'deviceModel': _deviceModel ?? Platform.localHostname,
if (userId != null) 'userId': userId,
if (ipCity != null) 'ipCity': ipCity,
if (ipRange != null) 'ipRange': ipRange,
},
);
_send(registerMsg);
Log.i(
'Signaling: Connected and registered (userId=$userId, alias=$alias, deviceModel=${_deviceModel ?? Platform.localHostname}, ipCity=$_ipCity)',
);
} catch (e) {
_isConnected = false;
_connectionController.add(false);
Log.e('Signaling: Connection failed: $e');
_scheduleReconnect();
}
}
Future<void> disconnect() async {
_isConnected = false;
_heartbeatTimer?.cancel();
_reconnectTimer?.cancel();
if (_deviceId != null) {
final leaveMsg = SignalingMessage(
type: SignalingMessageType.leave,
from: _deviceId!,
);
_send(leaveMsg);
}
await _channel?.sink.close();
_channel = null;
_connectionController.add(false);
Log.i('Signaling: Disconnected');
}
void updateUserId(String? userId) {
_userId = userId;
if (_isConnected && _deviceId != null && userId != null) {
final registerMsg = SignalingMessage(
type: SignalingMessageType.register,
from: _deviceId!,
payload: {
'alias': _alias ?? '闲言设备',
'fingerprint': _fingerprint ?? '',
'deviceType': 'mobile',
'deviceModel': _deviceModel ?? Platform.localHostname,
'userId': userId,
if (_ipCity != null) 'ipCity': _ipCity,
if (_ipRange != null) 'ipRange': _ipRange,
},
);
_send(registerMsg);
Log.i('Signaling: Updated userId=$userId, re-registered');
} else if (userId == null) {
_userId = null;
Log.i('Signaling: Cleared userId');
}
}
void _startHeartbeat() {
_heartbeatTimer?.cancel();
_heartbeatTimer = Timer.periodic(heartbeatInterval, (_) {
if (_isConnected && _deviceId != null) {
final hb = SignalingMessage(
type: SignalingMessageType.heartbeat,
from: _deviceId!,
);
_send(hb);
}
});
}
void _scheduleReconnect() {
if (_reconnectAttempts >= maxReconnectAttempts) {
Log.e('Signaling: Max reconnect attempts reached');
return;
}
_reconnectAttempts++;
final exponentialDelay =
baseReconnectDelay.inMilliseconds *
(1 << _reconnectAttempts.clamp(0, 10));
final jitter = _random.nextInt(1000);
final delayMs = (exponentialDelay + jitter).clamp(0, 30000);
Log.i(
'Signaling: Reconnecting in ${delayMs}ms (attempt $_reconnectAttempts)',
);
_reconnectTimer = Timer(Duration(milliseconds: delayMs), () {
if (_deviceId != null) {
Future.microtask(
() => connect(
_deviceId!,
alias: _alias,
fingerprint: _fingerprint,
userId: _userId,
ipCity: _ipCity,
ipRange: _ipRange,
deviceModel: _deviceModel,
),
).catchError((Object e) {
Log.w('Signaling: Reconnect attempt failed: $e');
});
}
});
}
Future<void> discoverDevices() async {
if (!_isConnected || _deviceId == null) return;
final msg = SignalingMessage(
type: SignalingMessageType.discover,
from: _deviceId!,
);
_send(msg);
}
Future<void> sendOffer(String targetId, String sdp) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.offer,
from: _deviceId!,
to: targetId,
payload: {'sdp': sdp},
);
_send(msg);
}
Future<void> sendAnswer(String targetId, String sdp) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.answer,
from: _deviceId!,
to: targetId,
payload: {'sdp': sdp},
);
_send(msg);
}
Future<void> sendIceCandidate(
String targetId,
Map<String, dynamic> candidate,
) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.iceCandidate,
from: _deviceId!,
to: targetId,
payload: candidate,
);
_send(msg);
}
Future<void> sendTextMessage(String targetId, String text) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.textMessage,
from: _deviceId!,
to: targetId,
payload: {
'text': text,
'timestamp': DateTime.now().millisecondsSinceEpoch,
},
);
_send(msg);
Log.i('Signaling: Text message sent to $targetId');
}
Future<void> sendFileMeta(
String targetId, {
required String fileName,
required int fileSize,
required String mimeType,
required String taskId,
}) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.fileMeta,
from: _deviceId!,
to: targetId,
payload: {
'fileName': fileName,
'fileSize': fileSize,
'mimeType': mimeType,
'taskId': taskId,
},
);
_send(msg);
}
Future<void> sendFileComplete(String targetId, String taskId) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.fileComplete,
from: _deviceId!,
to: targetId,
payload: {'taskId': taskId},
);
_send(msg);
}
Future<void> sendProgress(
String targetId,
String taskId,
double progress,
) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.progress,
from: _deviceId!,
to: targetId,
payload: {'taskId': taskId, 'progress': progress},
);
_send(msg);
}
Future<void> discoverMyDevices(String userId) async {
if (!_isConnected) return;
final msg = SignalingMessage(
type: SignalingMessageType.discoverMyDevices,
from: _deviceId ?? '',
payload: {'userId': userId},
);
_send(msg);
Log.i('Signaling: discoverMyDevices sent for userId=$userId');
}
Future<void> sendTransportNegotiate(
String targetId, {
required String transport,
Map<String, dynamic>? payload,
}) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.transportNegotiate,
from: _deviceId!,
to: targetId,
payload: {'transport': transport, if (payload != null) ...payload},
);
_send(msg);
Log.i(
'Signaling: transportNegotiate sent to $targetId, transport=$transport',
);
}
Future<void> sendWsRelay(
String targetId, {
required String relayType,
required Map<String, dynamic> payload,
}) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.wsRelay,
from: _deviceId!,
to: targetId,
payload: {'relayType': relayType, 'data': payload},
);
_send(msg);
}
Future<void> createPairingCode({String? alias, String? deviceType, String? pairingCode}) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.pairingCodeCreate,
from: _deviceId!,
payload: {
if (alias != null) 'alias': alias,
if (deviceType != null) 'deviceType': deviceType,
if (pairingCode != null) 'pairingCode': pairingCode,
},
);
_send(msg);
Log.i('Signaling: pairing-code-create sent');
}
Future<void> joinPairingCode(String code) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.pairingCodeJoin,
from: _deviceId!,
payload: {'pairingCode': code},
);
_send(msg);
Log.i('Signaling: pairing-code-join sent, code=$code');
}
Future<void> radarBroadcast({
String? alias,
String? ipRange,
String? ipCity,
}) async {
if (!_isConnected || _deviceId == null) return;
final msg = SignalingMessage(
type: SignalingMessageType.radarBroadcast,
from: _deviceId!,
payload: {
if (alias != null) 'alias': alias,
if (ipRange != null) 'ipRange': ipRange,
if (ipCity != null) 'ipCity': ipCity,
},
);
_send(msg);
Log.i('Signaling: radar-broadcast sent');
}
Future<void> radarScan({String? ipRange, String? ipCity}) async {
if (!_isConnected || _deviceId == null) return;
final msg = SignalingMessage(
type: SignalingMessageType.radarScan,
from: _deviceId!,
payload: {
if (ipRange != null) 'ipRange': ipRange,
if (ipCity != null) 'ipCity': ipCity,
},
);
_send(msg);
Log.i('Signaling: radar-scan sent');
}
Future<void> requestScreenShare(String targetId, {required String direction}) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.screenShareRequest,
from: _deviceId!,
to: targetId,
payload: {'direction': direction},
);
_send(msg);
Log.i('Signaling: screen-share-request sent to $targetId, direction=$direction');
}
Future<void> acceptScreenShare(String targetId, {required String direction}) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.screenShareAccept,
from: _deviceId!,
to: targetId,
payload: {'direction': direction},
);
_send(msg);
Log.i('Signaling: screen-share-accept sent to $targetId');
}
Future<void> rejectScreenShare(String targetId) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.screenShareReject,
from: _deviceId!,
to: targetId,
);
_send(msg);
Log.i('Signaling: screen-share-reject sent to $targetId');
}
Future<void> stopScreenShare(String targetId) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.screenShareStop,
from: _deviceId!,
to: targetId,
);
_send(msg);
Log.i('Signaling: screen-share-stop sent to $targetId');
}
Future<void> sendScreenShareFrame(
String targetId,
Map<String, dynamic> frameData,
) async {
if (!_isConnected || _deviceId == null) return;
final msg = SignalingMessage(
type: SignalingMessageType.screenShareFrame,
from: _deviceId!,
to: targetId,
payload: frameData,
);
_send(msg);
}
void _send(SignalingMessage message) {
if (_channel == null || !_isConnected) return;
try {
_channel!.sink.add(jsonEncode(message.toJson()));
Log.d('Signaling: Sent ${message.type.id}');
} catch (e) {
Log.e('Signaling: Send failed: $e');
}
}
Future<void> sendKeyExchange(String targetId, String publicKeyBase64) async {
if (!_isConnected || _deviceId == null) {
throw StateError('Signaling not connected');
}
final msg = SignalingMessage(
type: SignalingMessageType.keyExchange,
from: _deviceId!,
to: targetId,
payload: {'publicKey': publicKeyBase64},
);
_send(msg);
Log.i('Signaling: Key exchange sent to $targetId');
}
void sendCustomMessage(SignalingMessage message) => _send(message);
void _handleMessage(String data) {
try {
final json = jsonDecode(data) as Map<String, dynamic>;
final message = SignalingMessage.fromJson(json);
Log.d(
'Signaling: RECV type=${message.type.id}, keys=${json.keys.join(',')}',
);
switch (message.type) {
case SignalingMessageType.discover:
break;
case SignalingMessageType.discoverResponse:
_handleDiscoverResponse(json);
case SignalingMessageType.myDevicesResponse:
_handleMyDevicesResponse(json);
case SignalingMessageType.offer:
case SignalingMessageType.answer:
case SignalingMessageType.iceCandidate:
case SignalingMessageType.textMessage:
case SignalingMessageType.fileMeta:
case SignalingMessageType.fileChunk:
case SignalingMessageType.fileComplete:
case SignalingMessageType.progress:
case SignalingMessageType.transportNegotiate:
case SignalingMessageType.transportNegotiateResponse:
case SignalingMessageType.wsRelay:
case SignalingMessageType.deliveryAck:
case SignalingMessageType.chunkAck:
case SignalingMessageType.resumeRequest:
case SignalingMessageType.voiceMeta:
case SignalingMessageType.cloudCacheNotify:
case SignalingMessageType.keyExchange:
case SignalingMessageType.canvasStroke:
case SignalingMessageType.canvasCursor:
case SignalingMessageType.canvasJoin:
case SignalingMessageType.canvasLeave:
case SignalingMessageType.canvasSnapshot:
case SignalingMessageType.screenShareOffer:
case SignalingMessageType.screenShareAnswer:
case SignalingMessageType.screenShareStop:
case SignalingMessageType.pairingCodeCreate:
case SignalingMessageType.pairingCodeJoin:
case SignalingMessageType.radarBroadcast:
case SignalingMessageType.radarScan:
case SignalingMessageType.remoteInput:
if (!_messageController.isClosed) {
_messageController.add(message);
}
case SignalingMessageType.pairingCodeCreated:
case SignalingMessageType.pairingMatched:
if (!_pairingCodeController.isClosed) {
_pairingCodeController.add(json);
}
Log.i('Signaling: ${message.type.id} received');
case SignalingMessageType.radarDevices:
final devicesList = json['devices'] as List<dynamic>? ?? [];
final devices = devicesList
.map((d) => d as Map<String, dynamic>)
.toList();
if (!_radarDevicesController.isClosed) {
_radarDevicesController.add(devices);
}
Log.i('Signaling: radar-devices received, ${devices.length} devices');
case SignalingMessageType.screenShareRequest:
if (!_screenShareRequestController.isClosed) {
_screenShareRequestController.add(json);
}
Log.i('Signaling: screen-share-request received from ${message.from}');
case SignalingMessageType.screenShareFrame:
if (!_screenShareFrameController.isClosed) {
_screenShareFrameController.add(json);
}
case SignalingMessageType.screenShareAccept:
case SignalingMessageType.screenShareReject:
if (!_messageController.isClosed) {
_messageController.add(message);
}
Log.i('Signaling: ${message.type.id} received');
case SignalingMessageType.deviceOnline:
_handleDeviceOnline(message, isOnline: true);
case SignalingMessageType.deviceOffline:
_handleDeviceOnline(message, isOnline: false);
case SignalingMessageType.heartbeat:
break;
case SignalingMessageType.heartbeatAck:
_lastHeartbeatAck = DateTime.now();
Log.d('Signaling: heartbeat_ack received');
break;
case SignalingMessageType.ping:
_send(
SignalingMessage(
type: SignalingMessageType.pong,
from: _deviceId ?? '',
payload: {},
),
);
break;
case SignalingMessageType.pong:
_lastHeartbeatAck = DateTime.now();
Log.d('Signaling: pong received');
break;
case SignalingMessageType.registered:
final serverId = json['id'] as String?;
if (serverId != null && serverId.isNotEmpty) {
Log.i('Signaling: Server assigned id=$serverId (local=$_deviceId)');
}
break;
case SignalingMessageType.displayName:
final name = json['name'] as String? ?? json['displayName'] as String?;
if (name != null && name.isNotEmpty) {
_serverDisplayName = name;
Log.i('Signaling: Server set display name: $name');
} else {
Log.d('Signaling: Server set display name');
}
break;
case SignalingMessageType.peers:
final peerList = json['peers'] as List<dynamic>? ?? [];
Log.d('Signaling: Server reports ${peerList.length} peers in room');
break;
case SignalingMessageType.peerJoined:
Log.d('Signaling: Peer joined: ${json['peer']}');
break;
case SignalingMessageType.peerLeft:
Log.d('Signaling: Peer left: ${json['peerId']}');
break;
case SignalingMessageType.disconnect:
Log.w('Signaling: Server requested disconnect');
break;
case SignalingMessageType.error:
final errorMsg =
json['error'] as String? ?? json['message'] as String? ?? 'null';
Log.e('Signaling: Server error: $errorMsg');
break;
case SignalingMessageType.unknown:
Log.w(
'Signaling: Unknown message type: ${json['type']}, keys=${json.keys.join(',')}',
);
break;
default:
Log.w('Signaling: Unhandled message type: ${message.type.id}');
break;
}
} catch (e, st) {
Log.w('Signaling: Failed to parse message: $e\n$st');
}
}
void _handleDiscoverResponse(Map<String, dynamic> json) {
final devicesList =
(json['devices'] as List<dynamic>?) ??
((json['payload'] as Map<String, dynamic>?)?['devices']
as List<dynamic>?) ??
[];
Log.i(
'Signaling: discover response, ${devicesList.length} devices from server',
);
final devices = devicesList.map((d) {
final map = d as Map<String, dynamic>;
return TransferDevice(
id: map['fingerprint'] as String? ?? map['id'] as String? ?? '',
alias: map['alias'] as String? ?? 'Unknown',
deviceType: DeviceType.fromId(map['deviceType'] as String? ?? 'mobile'),
port: (map['port'] as num?)?.toInt() ?? 53317,
pairingMethod: PairingMethod.account,
preferredTransport: TransportType.webrtcP2p,
lastSeen: DateTime.now(),
isOnline: true,
isVerified: false,
fingerprint: map['fingerprint'] as String?,
);
}).toList();
if (!_onlineDevicesController.isClosed) {
_onlineDevicesController.add(devices);
}
}
void _handleMyDevicesResponse(Map<String, dynamic> json) {
final devicesList =
(json['devices'] as List<dynamic>?) ??
((json['payload'] as Map<String, dynamic>?)?['devices']
as List<dynamic>?) ??
[];
Log.i(
'Signaling: myDevicesResponse, raw devices count=${devicesList.length}',
);
if (devicesList.isEmpty) {
final error = json['error'] as String?;
Log.w(
'Signaling: myDevicesResponse empty${error != null ? ', error=$error' : ''}',
);
}
final devices = <TransferDevice>[];
for (final d in devicesList) {
try {
devices.add(TransferDevice.fromSignaling(d as Map<String, dynamic>));
} catch (e) {
Log.w('Signaling: myDevicesResponse parse device failed: $e');
}
}
if (!_myDevicesController.isClosed) {
_myDevicesController.add(devices);
}
Log.i('Signaling: myDevicesResponse parsed, ${devices.length} devices');
}
void _handleDeviceOnline(SignalingMessage message, {required bool isOnline}) {
final event = DeviceOnlineEvent(
deviceId: message.from,
isOnline: isOnline,
device: message.payload != null
? TransferDevice.fromSignaling(message.payload!)
: null,
timestamp: message.timestamp,
);
if (!_deviceOnlineController.isClosed) {
_deviceOnlineController.add(event);
}
Log.i(
'Signaling: device${isOnline ? 'Online' : 'Offline'} from=${message.from}',
);
}
Future<void> dispose() async {
await disconnect();
await _messageController.close();
await _connectionController.close();
await _onlineDevicesController.close();
await _myDevicesController.close();
await _deviceOnlineController.close();
await _pairingCodeController.close();
await _radarDevicesController.close();
await _screenShareRequestController.close();
await _screenShareFrameController.close();
}
}