Files
Public/apps/native/lib/im/tcp_client.dart
T
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

301 lines
8.0 KiB
Dart

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<String, dynamic> 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<String, _Pending> _pending = {};
final List<ImIncoming> 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<void> 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<void> _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<String?> 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<int> 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<String, dynamic>));
} 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<String, dynamic> raw = {};
try {
raw = Map<String, dynamic>.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;
}
}
}