Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 聊天消息的本地副本。云端只保留 7 天,客户端仍可查看本机历史消息。
|
||||
class ChatLocalStore {
|
||||
ChatLocalStore._();
|
||||
|
||||
static Future<List<Map<String, dynamic>>> load(String conversationId) async {
|
||||
if (conversationId.isEmpty) return [];
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final raw = p.getString('im.local.$conversationId');
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final list = jsonDecode(raw);
|
||||
if (list is! List) return [];
|
||||
return list.whereType<Map>().map((e) => Map<String, dynamic>.from(e)).toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> save(String conversationId, List<Map<String, dynamic>> items) async {
|
||||
if (conversationId.isEmpty) return;
|
||||
final p = await SharedPreferences.getInstance();
|
||||
final durable = items.where((e) => e['_pending'] != true).map((e) {
|
||||
final copy = Map<String, dynamic>.from(e)..remove('_pending');
|
||||
return copy;
|
||||
}).toList();
|
||||
await p.setString('im.local.$conversationId', jsonEncode(durable));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 会话级本地偏好:免打扰、置顶、背景、本地清空记录。
|
||||
class ChatPrefs {
|
||||
ChatPrefs._();
|
||||
static SharedPreferences? _p;
|
||||
|
||||
static Future<void> ensure() async {
|
||||
_p ??= await SharedPreferences.getInstance();
|
||||
// 旧版本曾在滑动过程中误写 hidden,导致会话读完后看似“自动删除”。
|
||||
// 升级时清理一次旧标记,之后只有确认“不显示/删除”才会再次隐藏。
|
||||
if (_p!.getBool('im.hidden.migration.v3') != true) {
|
||||
for (final key
|
||||
in _p!.getKeys().where((k) => k.startsWith('im.hidden.'))) {
|
||||
await _p!.remove(key);
|
||||
}
|
||||
await _p!.setBool('im.hidden.migration.v3', true);
|
||||
}
|
||||
}
|
||||
|
||||
static bool muted(String id) =>
|
||||
id.isEmpty ? false : (_p?.getBool('im.mute.$id') ?? false);
|
||||
static bool pinned(String id) =>
|
||||
id.isEmpty ? false : (_p?.getBool('im.pin.$id') ?? false);
|
||||
static bool hidden(String id) =>
|
||||
id.isEmpty ? false : (_p?.getBool('im.hidden.$id') ?? false);
|
||||
static int? bgColor(String id) => id.isEmpty ? null : _p?.getInt('im.bg.$id');
|
||||
static DateTime? clearedAt(String id) {
|
||||
if (id.isEmpty) return null;
|
||||
final ms = _p?.getInt('im.cleared.$id');
|
||||
if (ms == null || ms <= 0) return null;
|
||||
return DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
}
|
||||
|
||||
static Future<void> setMuted(String id, bool v) async {
|
||||
await ensure();
|
||||
await _p!.setBool('im.mute.$id', v);
|
||||
}
|
||||
|
||||
static Future<void> setPinned(String id, bool v) async {
|
||||
await ensure();
|
||||
await _p!.setBool('im.pin.$id', v);
|
||||
}
|
||||
|
||||
static Future<void> setHidden(String id, bool v) async {
|
||||
await ensure();
|
||||
await _p!.setBool('im.hidden.$id', v);
|
||||
}
|
||||
|
||||
static Future<void> setBg(String id, int color) async {
|
||||
await ensure();
|
||||
await _p!.setInt('im.bg.$id', color);
|
||||
}
|
||||
|
||||
static Future<void> clearHistory(String id) async {
|
||||
await ensure();
|
||||
await _p!.setInt('im.cleared.$id', DateTime.now().millisecondsSinceEpoch);
|
||||
}
|
||||
|
||||
static int fakeUnread(String id) =>
|
||||
id.isEmpty ? 0 : (_p?.getInt('im.fakeUnread.$id') ?? 0);
|
||||
|
||||
static Future<void> setFakeUnread(String id, int n) async {
|
||||
await ensure();
|
||||
if (n <= 0) {
|
||||
await _p!.remove('im.fakeUnread.$id');
|
||||
} else {
|
||||
await _p!.setInt('im.fakeUnread.$id', n);
|
||||
}
|
||||
}
|
||||
|
||||
/// 语音转文字结果,按消息 id 或 fileId 缓存。
|
||||
static String? voiceText(String key) {
|
||||
if (key.isEmpty) return null;
|
||||
final v = _p?.getString('im.voiceText.$key');
|
||||
if (v == null || v.isEmpty) return null;
|
||||
return v;
|
||||
}
|
||||
|
||||
static Future<void> setVoiceText(String key, String text) async {
|
||||
if (key.isEmpty) return;
|
||||
await ensure();
|
||||
final t = text.trim();
|
||||
if (t.isEmpty) {
|
||||
await _p!.remove('im.voiceText.$key');
|
||||
} else {
|
||||
await _p!.setString('im.voiceText.$key', t);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// 与官方 MobileIMSDK 对齐的生命周期:登录前 init,退出 release。
|
||||
/// Android 走 MethodChannel;Windows/Linux 无原生插件时回落到 Dart TCP。
|
||||
class ImBridge {
|
||||
static const _ch = MethodChannel('com.fysxkj.oa/im');
|
||||
bool nativeReady = false;
|
||||
String engine = 'dart-tcp';
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
final r = await _ch.invokeMethod<Map>('init');
|
||||
nativeReady = r?['ok'] == true || r?['initialized'] == true;
|
||||
engine = '${r?['engine'] ?? 'dart-tcp'}';
|
||||
} on MissingPluginException {
|
||||
nativeReady = false;
|
||||
engine = 'dart-tcp';
|
||||
} catch (_) {
|
||||
nativeReady = false;
|
||||
engine = 'dart-tcp';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> release() async {
|
||||
try {
|
||||
await _ch.invokeMethod('release');
|
||||
} catch (_) {}
|
||||
nativeReady = false;
|
||||
}
|
||||
|
||||
Future<String> status() async {
|
||||
try {
|
||||
final r = await _ch.invokeMethod<String>('engine');
|
||||
if (r != null && r.isNotEmpty) engine = r;
|
||||
} catch (_) {}
|
||||
return engine;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../session/session.dart';
|
||||
|
||||
/// 监听网络切换与应用恢复,触发 IM 极速重连。
|
||||
class ImLifecycle with WidgetsBindingObserver {
|
||||
ImLifecycle(this.session);
|
||||
final SessionStore session;
|
||||
StreamSubscription<List<ConnectivityResult>>? _netSub;
|
||||
List<ConnectivityResult> _lastNet = const [];
|
||||
|
||||
void start() {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_netSub = Connectivity().onConnectivityChanged.listen(_onNet);
|
||||
Connectivity().checkConnectivity().then((r) => _lastNet = r);
|
||||
}
|
||||
|
||||
void stop() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_netSub?.cancel();
|
||||
_netSub = null;
|
||||
}
|
||||
|
||||
void _onNet(List<ConnectivityResult> next) {
|
||||
final wasOffline = _lastNet.every((e) => e == ConnectivityResult.none);
|
||||
final nowOnline = next.any((e) => e != ConnectivityResult.none);
|
||||
_lastNet = next;
|
||||
if (wasOffline && nowOnline && session.signedIn) {
|
||||
unawaited(session.connectIm(force: true));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed && session.signedIn) {
|
||||
unawaited(session.connectIm(force: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 本地记录各会话已同步到的最大 seq,断线重连后用于 gap fill。
|
||||
class ImSeqStore {
|
||||
ImSeqStore._();
|
||||
static SharedPreferences? _p;
|
||||
|
||||
static Future<void> ensure() async {
|
||||
_p ??= await SharedPreferences.getInstance();
|
||||
}
|
||||
|
||||
static Future<int> lastSeq(String conversationId) async {
|
||||
if (conversationId.isEmpty) return 0;
|
||||
await ensure();
|
||||
return _p?.getInt('im.seq.$conversationId') ?? 0;
|
||||
}
|
||||
|
||||
static Future<void> setLastSeq(String conversationId, int seq) async {
|
||||
if (conversationId.isEmpty || seq <= 0) return;
|
||||
await ensure();
|
||||
final prev = _p?.getInt('im.seq.$conversationId') ?? 0;
|
||||
if (seq > prev) await _p!.setInt('im.seq.$conversationId', seq);
|
||||
}
|
||||
|
||||
static Future<void> applyItems(String conversationId, Iterable<Map<String, dynamic>> items) async {
|
||||
var max = 0;
|
||||
for (final e in items) {
|
||||
final s = (e['seq'] as num?)?.toInt() ?? 0;
|
||||
if (s > max) max = s;
|
||||
}
|
||||
if (max > 0) await setLastSeq(conversationId, max);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/// 姓名首字母。按百家姓,避免大量同事被丢进「#」。
|
||||
const _surnames = {
|
||||
'赵': 'Z', '钱': 'Q', '孙': 'S', '李': 'L', '周': 'Z', '吴': 'W', '郑': 'Z', '王': 'W',
|
||||
'冯': 'F', '陈': 'C', '褚': 'C', '卫': 'W', '蒋': 'J', '沈': 'S', '韩': 'H', '杨': 'Y',
|
||||
'朱': 'Z', '秦': 'Q', '尤': 'Y', '许': 'X', '何': 'H', '吕': 'L', '施': 'S', '张': 'Z',
|
||||
'孔': 'K', '曹': 'C', '严': 'Y', '华': 'H', '金': 'J', '魏': 'W', '陶': 'T', '姜': 'J',
|
||||
'戚': 'Q', '谢': 'X', '邹': 'Z', '喻': 'Y', '柏': 'B', '水': 'S', '窦': 'D', '章': 'Z',
|
||||
'云': 'Y', '苏': 'S', '潘': 'P', '葛': 'G', '奚': 'X', '范': 'F', '彭': 'P', '郎': 'L',
|
||||
'鲁': 'L', '韦': 'W', '昌': 'C', '马': 'M', '苗': 'M', '凤': 'F', '花': 'H', '方': 'F',
|
||||
'俞': 'Y', '任': 'R', '袁': 'Y', '柳': 'L', '酆': 'F', '鲍': 'B', '史': 'S', '唐': 'T',
|
||||
'费': 'F', '廉': 'L', '岑': 'C', '薛': 'X', '雷': 'L', '贺': 'H', '倪': 'N', '汤': 'T',
|
||||
'滕': 'T', '殷': 'Y', '罗': 'L', '毕': 'B', '郝': 'H', '邬': 'W', '安': 'A', '常': 'C',
|
||||
'乐': 'Y', '于': 'Y', '时': 'S', '傅': 'F', '皮': 'P', '卞': 'B', '齐': 'Q', '康': 'K',
|
||||
'伍': 'W', '余': 'Y', '元': 'Y', '卜': 'B', '顾': 'G', '孟': 'M', '平': 'P', '黄': 'H',
|
||||
'和': 'H', '穆': 'M', '萧': 'X', '尹': 'Y', '姚': 'Y', '邵': 'S', '湛': 'Z', '汪': 'W',
|
||||
'祁': 'Q', '毛': 'M', '禹': 'Y', '狄': 'D', '米': 'M', '贝': 'B', '明': 'M', '臧': 'Z',
|
||||
'计': 'J', '伏': 'F', '成': 'C', '戴': 'D', '谈': 'T', '宋': 'S', '茅': 'M', '庞': 'P',
|
||||
'熊': 'X', '纪': 'J', '舒': 'S', '屈': 'Q', '项': 'X', '祝': 'Z', '董': 'D', '梁': 'L',
|
||||
'杜': 'D', '阮': 'R', '蓝': 'L', '闵': 'M', '席': 'X', '季': 'J', '麻': 'M', '强': 'Q',
|
||||
'贾': 'J', '路': 'L', '娄': 'L', '危': 'W', '江': 'J', '童': 'T', '颜': 'Y', '郭': 'G',
|
||||
'梅': 'M', '盛': 'S', '林': 'L', '刁': 'D', '钟': 'Z', '徐': 'X', '邱': 'Q', '骆': 'L',
|
||||
'高': 'G', '夏': 'X', '蔡': 'C', '田': 'T', '樊': 'F', '胡': 'H', '凌': 'L', '霍': 'H',
|
||||
'虞': 'Y', '万': 'W', '支': 'Z', '柯': 'K', '昝': 'Z', '管': 'G', '卢': 'L', '莫': 'M',
|
||||
'经': 'J', '房': 'F', '裘': 'Q', '缪': 'M', '干': 'G', '解': 'X', '应': 'Y', '宗': 'Z',
|
||||
'丁': 'D', '宣': 'X', '贲': 'B', '邓': 'D', '郁': 'Y', '单': 'S', '杭': 'H', '洪': 'H',
|
||||
'包': 'B', '诸': 'Z', '左': 'Z', '石': 'S', '崔': 'C', '吉': 'J', '钮': 'N', '龚': 'G',
|
||||
'程': 'C', '嵇': 'J', '邢': 'X', '滑': 'H', '裴': 'P', '陆': 'L', '荣': 'R', '翁': 'W',
|
||||
'荀': 'X', '羊': 'Y', '於': 'Y', '惠': 'H', '甄': 'Z', '曲': 'Q', '家': 'J', '封': 'F',
|
||||
'芮': 'R', '羿': 'Y', '储': 'C', '靳': 'J', '汲': 'J', '邴': 'B', '糜': 'M', '松': 'S',
|
||||
'井': 'J', '段': 'D', '富': 'F', '巫': 'W', '乌': 'W', '焦': 'J', '巴': 'B', '弓': 'G',
|
||||
'牧': 'M', '隗': 'K', '山': 'S', '谷': 'G', '车': 'C', '侯': 'H', '宓': 'M', '蓬': 'P',
|
||||
'全': 'Q', '郗': 'X', '班': 'B', '仰': 'Y', '秋': 'Q', '仲': 'Z', '伊': 'Y', '宫': 'G',
|
||||
'宁': 'N', '仇': 'Q', '栾': 'L', '暴': 'B', '甘': 'G', '钭': 'T', '厉': 'L', '戎': 'R',
|
||||
'祖': 'Z', '武': 'W', '符': 'F', '刘': 'L', '景': 'J', '詹': 'Z', '束': 'S', '龙': 'L',
|
||||
'叶': 'Y', '幸': 'X', '司': 'S', '韶': 'S', '郜': 'G', '黎': 'L', '蓟': 'J', '薄': 'B',
|
||||
'印': 'Y', '宿': 'S', '白': 'B', '怀': 'H', '蒲': 'P', '邰': 'T', '从': 'C', '鄂': 'E',
|
||||
'索': 'S', '咸': 'X', '籍': 'J', '赖': 'L', '卓': 'Z', '蔺': 'L', '屠': 'T', '蒙': 'M',
|
||||
'池': 'C', '乔': 'Q', '阴': 'Y', '胥': 'X', '能': 'N', '苍': 'C', '双': 'S', '闻': 'W',
|
||||
'莘': 'S', '党': 'D', '翟': 'Z', '谭': 'T', '贡': 'G', '劳': 'L', '逄': 'P', '姬': 'J',
|
||||
'申': 'S', '扶': 'F', '堵': 'D', '冉': 'R', '宰': 'Z', '郦': 'L', '雍': 'Y', '郤': 'X',
|
||||
'璩': 'Q', '桑': 'S', '桂': 'G', '濮': 'P', '牛': 'N', '寿': 'S', '通': 'T', '边': 'B',
|
||||
'扈': 'H', '燕': 'Y', '冀': 'J', '郏': 'J', '浦': 'P', '尚': 'S', '农': 'N', '温': 'W',
|
||||
'别': 'B', '庄': 'Z', '晏': 'Y', '柴': 'C', '瞿': 'Q', '阎': 'Y', '充': 'C', '慕': 'M',
|
||||
'连': 'L', '茹': 'R', '习': 'X', '宦': 'H', '艾': 'A', '鱼': 'Y', '容': 'R', '向': 'X',
|
||||
'古': 'G', '易': 'Y', '慎': 'S', '戈': 'G', '廖': 'L', '庾': 'Y', '终': 'Z', '暨': 'J',
|
||||
'居': 'J', '衡': 'H', '步': 'B', '都': 'D', '耿': 'G', '满': 'M', '弘': 'H', '匡': 'K',
|
||||
'国': 'G', '文': 'W', '寇': 'K', '广': 'G', '禄': 'L', '阙': 'Q', '东': 'D', '欧': 'O',
|
||||
'殳': 'S', '沃': 'W', '利': 'L', '蔚': 'W', '越': 'Y', '夔': 'K', '隆': 'L', '师': 'S',
|
||||
'巩': 'G', '厍': 'S', '聂': 'N', '晁': 'C', '勾': 'G', '敖': 'A', '融': 'R', '冷': 'L',
|
||||
'訾': 'Z', '辛': 'X', '阚': 'K', '那': 'N', '简': 'J', '饶': 'R', '空': 'K', '曾': 'Z',
|
||||
'毋': 'W', '沙': 'S', '乜': 'N', '养': 'Y', '鞠': 'J', '须': 'X', '丰': 'F', '巢': 'C',
|
||||
'关': 'G', '蒯': 'K', '相': 'X', '查': 'Z', '后': 'H', '荆': 'J', '红': 'H', '游': 'Y',
|
||||
'竺': 'Z', '权': 'Q', '逯': 'L', '盖': 'G', '益': 'Y', '桓': 'H', '公': 'G', '万俟': 'M',
|
||||
'司马': 'S', '上官': 'S', '欧阳': 'O', '夏侯': 'X', '诸葛': 'Z', '闻人': 'W', '东方': 'D',
|
||||
'赫连': 'H', '皇甫': 'H', '尉迟': 'Y', '公羊': 'G', '澹台': 'T', '公冶': 'G', '宗政': 'Z',
|
||||
'濮阳': 'P', '淳于': 'C', '单于': 'C', '太叔': 'T', '申屠': 'S', '公孙': 'G', '仲孙': 'Z',
|
||||
'轩辕': 'X', '令狐': 'L', '钟离': 'Z', '宇文': 'Y', '长孙': 'Z', '慕容': 'M', '鲜于': 'X',
|
||||
'闾丘': 'L', '司徒': 'S', '司空': 'S', '丌官': 'Q', '司寇': 'S', '仉': 'Z', '督': 'D',
|
||||
'子车': 'Z', '颛孙': 'Z', '端木': 'D', '巫马': 'W', '公西': 'G', '漆雕': 'Q', '乐正': 'Y',
|
||||
'壤驷': 'R', '公良': 'G', '拓跋': 'T', '夹谷': 'J', '宰父': 'Z', '谷梁': 'G', '晋': 'J',
|
||||
'楚': 'C', '闫': 'Y', '法': 'F', '汝': 'R', '鄢': 'Y', '涂': 'T', '钦': 'Q', '段干': 'D',
|
||||
'百里': 'B', '东郭': 'D', '南门': 'N', '呼延': 'H', '归': 'G', '海': 'H', '羊舌': 'Y',
|
||||
'微生': 'W', '岳': 'Y', '帅': 'S', '缑': 'G', '亢': 'K', '况': 'K', '有': 'Y',
|
||||
'琴': 'Q', '梁丘': 'L', '左丘': 'Z', '东门': 'D', '西门': 'X', '商': 'S', '牟': 'M',
|
||||
'佘': 'S', '佴': 'N', '伯': 'B', '赏': 'S', '南宫': 'N', '墨': 'M', '哈': 'H', '谯': 'Q',
|
||||
'笪': 'D', '年': 'N', '爱': 'A', '阳': 'Y', '佟': 'T', '言': 'Y', '福': 'F', '西': 'X',
|
||||
'南': 'N', '北': 'B',
|
||||
};
|
||||
|
||||
String letterOf(String name) {
|
||||
final n = name.trim();
|
||||
if (n.isEmpty) return '#';
|
||||
if (n.length >= 2) {
|
||||
final two = _surnames[n.substring(0, 2)];
|
||||
if (two != null) return two;
|
||||
}
|
||||
final c = n[0];
|
||||
if (RegExp(r'[A-Za-z]').hasMatch(c)) return c.toUpperCase();
|
||||
if (RegExp(r'[0-9]').hasMatch(c)) return '#';
|
||||
return _surnames[c] ?? '#';
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
/// MobileIMSDK 官方 TCP 帧:4 字节大端长度 + UTF-8 JSON Protocal。
|
||||
class ClientType {
|
||||
static const login = 0;
|
||||
static const keepAlive = 1;
|
||||
static const commonData = 2;
|
||||
static const logout = 3;
|
||||
static const received = 4;
|
||||
static const echo = 5;
|
||||
}
|
||||
|
||||
class ServerType {
|
||||
static const login = 50;
|
||||
static const keepAlive = 51;
|
||||
static const error = 52;
|
||||
static const echo = 53;
|
||||
static const kickout = 54;
|
||||
}
|
||||
|
||||
class Protocal {
|
||||
Protocal({
|
||||
required this.type,
|
||||
this.dataContent = '',
|
||||
this.from = '0',
|
||||
this.to = '0',
|
||||
this.fp,
|
||||
this.qos = false,
|
||||
this.typeu = -1,
|
||||
this.sm = -1,
|
||||
});
|
||||
|
||||
int type;
|
||||
String dataContent;
|
||||
String from;
|
||||
String to;
|
||||
String? fp;
|
||||
bool qos;
|
||||
int typeu;
|
||||
int sm;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'bridge': false,
|
||||
'type': type,
|
||||
'dataContent': dataContent,
|
||||
'from': from,
|
||||
'to': to,
|
||||
'fp': fp,
|
||||
'QoS': qos,
|
||||
'typeu': typeu,
|
||||
'sm': sm,
|
||||
};
|
||||
|
||||
factory Protocal.fromJson(Map<String, dynamic> j) => Protocal(
|
||||
type: (j['type'] as num?)?.toInt() ?? 0,
|
||||
dataContent: '${j['dataContent'] ?? ''}',
|
||||
from: '${j['from'] ?? '0'}',
|
||||
to: '${j['to'] ?? '0'}',
|
||||
fp: j['fp']?.toString(),
|
||||
qos: j['QoS'] == true || j['qos'] == true,
|
||||
typeu: (j['typeu'] as num?)?.toInt() ?? -1,
|
||||
sm: (j['sm'] as num?)?.toInt() ?? -1,
|
||||
);
|
||||
}
|
||||
|
||||
Uint8List encodeFrame(Protocal p) {
|
||||
final body = utf8.encode(jsonEncode(p.toJson()));
|
||||
final header = ByteData(4)..setUint32(0, body.length);
|
||||
return Uint8List.fromList([...header.buffer.asUint8List(), ...body]);
|
||||
}
|
||||
|
||||
const maxBody = 6 * 1024;
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 自定义表情:服务端 fileId 列表。GIF / 表情面板发出的都走这里。
|
||||
class StickerStore {
|
||||
StickerStore._();
|
||||
static const _key = 'im.stickers';
|
||||
static SharedPreferences? _p;
|
||||
static List<String> _ids = [];
|
||||
|
||||
static List<String> get ids => List.unmodifiable(_ids);
|
||||
|
||||
static Future<void> ensure() async {
|
||||
_p ??= await SharedPreferences.getInstance();
|
||||
_ids = _p!.getStringList(_key) ?? [];
|
||||
}
|
||||
|
||||
static bool has(String fileId) => fileId.isNotEmpty && _ids.contains(fileId);
|
||||
|
||||
static Future<void> add(String fileId) async {
|
||||
if (fileId.isEmpty) return;
|
||||
await ensure();
|
||||
if (_ids.contains(fileId)) return;
|
||||
_ids = [fileId, ..._ids];
|
||||
await _p!.setStringList(_key, _ids);
|
||||
}
|
||||
|
||||
static Future<void> remove(String fileId) async {
|
||||
await ensure();
|
||||
_ids = _ids.where((e) => e != fileId).toList();
|
||||
await _p!.setStringList(_key, _ids);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user