import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:uuid/uuid.dart'; import 'protocol.dart'; import '../device/device_bridge.dart'; import '../device/push_bridge.dart'; class ImIncoming { ImIncoming({ required this.from, required this.text, this.kind = 'chat', this.fp, this.seq = 0, this.raw = const {}, }); final String from; final String text; final String kind; final String? fp; final int seq; final Map raw; } class _Pending { _Pending(this.p); final Protocal p; int tries = 0; } /// 官方 TCP ClientCoreSDK 等价实现:登录、心跳、通用数据、QoS 应答与重发。 class ImTcpClient extends ChangeNotifier { Socket? _socket; Uint8List _buf = Uint8List(0); Timer? _hb; Timer? _qos; Timer? _reconnect; bool connected = false; bool loggedIn = false; String lastError = ''; String userId = ''; int firstLoginTime = 0; final Map _pending = {}; final List inbox = []; final _uuid = const Uuid(); String _host = ''; int _port = 0; String _token = ''; bool _want = false; int _tries = 0; DateTime _lastHbAck = DateTime.now(); VoidCallback? onLoggedIn; Future connect({ required String host, required int port, required String userId, required String token, }) async { _host = host; _port = port; _token = token; this.userId = userId; _want = true; _tries = 0; await _open(); } Future _open() async { _tearDownSocket(); lastError = ''; try { _socket = await Socket.connect(_host, _port, timeout: const Duration(seconds: 8)); connected = true; _tries = 0; notifyListeners(); _socket!.listen(_onData, onDone: _onDrop, onError: (_) => _onDrop()); firstLoginTime = DateTime.now().millisecondsSinceEpoch; _send(Protocal( type: ClientType.login, from: userId, dataContent: jsonEncode({ 'loginUserId': userId, 'loginToken': _token, 'clientKind': 'mobile', 'extra': '', 'firstLoginTime': firstLoginTime, }), )); _hb = Timer.periodic(const Duration(seconds: 5), (_) { if (loggedIn) { if (DateTime.now().difference(_lastHbAck).inSeconds > 25) { _onDrop(); return; } _send(Protocal(type: ClientType.keepAlive, from: userId)); } }); _qos = Timer.periodic(const Duration(seconds: 4), (_) => _retryQos()); } catch (e) { lastError = 'IM 连接失败:$e'; connected = false; loggedIn = false; notifyListeners(); _scheduleReconnect(); } } void _onDrop() { final was = connected || loggedIn; _tearDownSocket(); if (was) notifyListeners(); if (_want) _scheduleReconnect(); } void _scheduleReconnect() { if (!_want) return; _reconnect?.cancel(); _tries = (_tries + 1).clamp(1, 12); _reconnect = Timer(Duration(seconds: _tries < 4 ? 2 : 5), () { if (_want && !loggedIn) _open(); }); } void disconnect() { _want = false; _reconnect?.cancel(); _reconnect = null; _tearDownSocket(); notifyListeners(); } void _tearDownSocket() { _hb?.cancel(); _qos?.cancel(); _hb = null; _qos = null; try { _socket?.destroy(); } catch (_) {} _socket = null; _buf = Uint8List(0); connected = false; loggedIn = false; } void bump() { notifyListeners(); } Future sendChat(String toUserId, String text) async { if (!loggedIn) return null; final fp = _uuid.v4(); final p = Protocal( type: ClientType.commonData, from: userId, to: toUserId, dataContent: jsonEncode({'kind': 'chat', 'text': text, 'fromUserId': userId}), qos: true, fp: fp, typeu: 0, sm: DateTime.now().millisecondsSinceEpoch, ); _pending[fp] = _Pending(p); _send(p); return fp; } void _retryQos() { for (final e in [..._pending.entries]) { e.value.tries++; if (e.value.tries > 3) { _pending.remove(e.key); continue; } _send(e.value.p); } } void _send(Protocal p) { final s = _socket; if (s == null) return; try { s.add(encodeFrame(p)); } catch (_) {} } void _onData(List chunk) { _buf = Uint8List.fromList([..._buf, ...chunk]); while (_buf.length >= 4) { final len = ByteData.sublistView(_buf).getUint32(0); if (len <= 0 || len > maxBody) { disconnect(); return; } if (_buf.length < 4 + len) break; final body = utf8.decode(_buf.sublist(4, 4 + len)); _buf = Uint8List.fromList(_buf.sublist(4 + len)); try { _handle(Protocal.fromJson(jsonDecode(body) as Map)); } catch (_) {} } } void _handle(Protocal p) { switch (p.type) { case ServerType.login: try { final info = jsonDecode(p.dataContent.isEmpty ? '{}' : p.dataContent); loggedIn = info is Map && info['code'] == 0; if (loggedIn && info['firstLoginTime'] != null) { firstLoginTime = (info['firstLoginTime'] as num).toInt(); } if (!loggedIn) lastError = 'IM 登录被拒绝'; } catch (_) { loggedIn = false; } if (loggedIn) { _lastHbAck = DateTime.now(); onLoggedIn?.call(); } notifyListeners(); break; case ServerType.keepAlive: case ServerType.echo: _lastHbAck = DateTime.now(); break; case ServerType.kickout: _tearDownSocket(); notifyListeners(); if (_want) _scheduleReconnect(); break; case ClientType.received: _pending.remove(p.dataContent); break; case ClientType.commonData: if (p.qos && p.fp != null) { _send(Protocal( type: ClientType.received, from: userId, to: p.from, dataContent: p.fp!)); } String text = p.dataContent; var kind = 'chat'; Map raw = {}; try { raw = Map.from(jsonDecode(p.dataContent) as Map); text = '${raw['text'] ?? raw['title'] ?? raw['body'] ?? text}'; kind = '${raw['kind'] ?? 'chat'}'; } catch (_) {} final seq = (raw['seq'] as num?)?.toInt() ?? (p.sm > 0 ? p.sm : 0); final fp = '${raw['fingerprint'] ?? p.fp ?? ''}'; if (fp.isNotEmpty) { final dup = inbox.any((e) => e.fp == fp); if (dup) break; } inbox.insert( 0, ImIncoming( from: p.from, text: text, kind: kind, fp: fp.isEmpty ? p.fp : fp, seq: seq, raw: raw)); final terminalCall = kind == 'call' && const {'end', 'reject', 'timeout'} .contains('${raw['action'] ?? ''}'); if (terminalCall) { unawaited(DeviceBridge.cancelCallNotification()); } else if (p.from != userId && kind != 'receipt' && kind != 'signal') { unawaited(PushBridge.showOnline( '${raw['fromName'] ?? raw['title'] ?? '风影办公'}', text, extras: { if (raw['conversationId'] != null) 'conversationId': '${raw['conversationId']}', if (raw['kind'] != null) 'kind': '$kind', if (raw['callId'] != null) 'callId': '${raw['callId']}', if (raw['callKind'] != null) 'callKind': '${raw['callKind']}', if (raw['fromName'] != null) 'fromName': '${raw['fromName']}', if (raw['fromUserId'] != null) 'peerId': '${raw['fromUserId']}', if (raw['action'] != null) 'action': '${raw['action']}', }, )); } if (inbox.length > 200) inbox.removeLast(); notifyListeners(); break; } } }