76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
301 lines
8.0 KiB
Dart
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;
|
|
}
|
|
}
|
|
}
|