1. 新增工作台三栏布局模式,适配宽屏设备 2. 添加跨平台系统托盘支持,新增托盘图标资源 3. 修复工作台模式下导航返回异常问题 4. 统一JSON类型安全解析,替换硬类型转换 5. 增加macOS深度链接支持,统一渠道分发信息 6. 优化部分页面生命周期和状态加载逻辑 7. 移除废弃的nearby_connections依赖
999 lines
31 KiB
Dart
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();
|
|
}
|
|
}
|