- IMService 记录 onConnectFailed 的 code/error(lastConnectError) - SDK 未初始化成功时 login 直接抛错,不再无限等待 - 登录页失败提示下附自检结果:账号服务/消息接口/消息长连接 通不通 - 版本号 1.0.2+3
314 lines
10 KiB
Dart
314 lines
10 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_openim_sdk/flutter_openim_sdk.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
|
|
import '../config.dart';
|
|
import '../models/signaling.dart';
|
|
|
|
/// SDK 与服务器的连接状态
|
|
enum ConnectStatus { idle, connecting, success, failed }
|
|
|
|
/// 通话信令的自定义消息类型(与官方样板工程保持一致,保证互通)
|
|
class SignalingType {
|
|
SignalingType._();
|
|
|
|
static const int callingInvite = 200; // 发起呼叫
|
|
static const int callingAccept = 201; // 接听
|
|
static const int callingReject = 202; // 拒接
|
|
static const int callingCancel = 203; // 取消呼叫
|
|
static const int callingHungup = 204; // 挂断
|
|
}
|
|
|
|
/// OpenIM SDK 的统一封装:初始化、登录、监听、会话列表。
|
|
/// 用 ChangeNotifier 做状态分发,不引入额外状态管理框架。
|
|
class IMService extends ChangeNotifier {
|
|
IMService._();
|
|
|
|
static final IMService instance = IMService._();
|
|
|
|
/// SDK 是否已初始化(App 启动时做一次)
|
|
bool sdkReady = false;
|
|
|
|
/// 与服务器的连接状态(断网横幅用)
|
|
ConnectStatus connectStatus = ConnectStatus.idle;
|
|
|
|
/// 最近一次连接失败的原始错误(诊断展示用)
|
|
String? lastConnectError;
|
|
|
|
/// 是否已登录
|
|
bool loggedIn = false;
|
|
|
|
/// 会话首次同步中(消息列表的「正在加载」态)
|
|
bool syncing = true;
|
|
|
|
/// 会话同步失败(消息列表的「加载失败」态)
|
|
bool syncFailed = false;
|
|
|
|
/// 会话同步是否完成过至少一次(区分「正在加载」和「还没有消息」)
|
|
bool conversationsLoaded = false;
|
|
|
|
/// 会话列表(置顶在前,其余按最新消息时间倒序)
|
|
List<ConversationInfo> conversations = [];
|
|
|
|
/// 当前登录用户信息
|
|
UserInfo? selfInfo;
|
|
|
|
/// 当前登录凭证(发起通话取 LiveKit token 时要用)
|
|
String? currentUserID;
|
|
String? currentToken;
|
|
|
|
/// 被踢下线 / token 失效时的回调(由 main.dart 设置:清缓存、回登录页)
|
|
void Function()? onForceLogout;
|
|
|
|
/// 收到新消息(聊天页订阅)
|
|
final StreamController<Message> _newMsgController = StreamController<Message>.broadcast();
|
|
Stream<Message> get onNewMessage => _newMsgController.stream;
|
|
|
|
/// 消息被撤回(撤回方的 clientMsgID,聊天页订阅)
|
|
final StreamController<String> _revokeController = StreamController<String>.broadcast();
|
|
Stream<String> get onMessageRevoked => _revokeController.stream;
|
|
|
|
/// 收到通话信令(仅在线自定义消息,通话模块订阅)
|
|
final StreamController<Message> _signalingController = StreamController<Message>.broadcast();
|
|
Stream<Message> get onSignaling => _signalingController.stream;
|
|
|
|
int get platformID => Platform.isIOS ? IMPlatform.ios : IMPlatform.android;
|
|
|
|
/// App 启动时初始化 SDK(只做一次)。
|
|
/// 用共享 Future 防止超时后重复触发 initSDK:第一次调用还在跑时,
|
|
/// 后续调用直接复用同一个 Future;失败了则允许下次重试。
|
|
Future<void>? _initFuture;
|
|
|
|
Future<void> init() {
|
|
if (sdkReady) return Future.value();
|
|
final inFlight = _initFuture;
|
|
if (inFlight != null) return inFlight;
|
|
final future = _doInit();
|
|
_initFuture = future;
|
|
return future.whenComplete(() {
|
|
// 失败的初始化允许重试;成功的保留 sdkReady 标记即可
|
|
if (!sdkReady && identical(_initFuture, future)) _initFuture = null;
|
|
});
|
|
}
|
|
|
|
Future<void> _doInit() async {
|
|
final dir = await getApplicationDocumentsDirectory();
|
|
final dataDir = '${dir.path}/openim';
|
|
await Directory(dataDir).create(recursive: true);
|
|
final ok = await OpenIM.iMManager.initSDK(
|
|
platformID: platformID,
|
|
apiAddr: apiAddr,
|
|
wsAddr: wsAddr,
|
|
dataDir: dataDir,
|
|
logFilePath: dataDir,
|
|
logLevel: 6,
|
|
listener: OnConnectListener(
|
|
onConnecting: () {
|
|
connectStatus = ConnectStatus.connecting;
|
|
notifyListeners();
|
|
},
|
|
onConnectFailed: (code, error) {
|
|
connectStatus = ConnectStatus.failed;
|
|
lastConnectError = '$code $error';
|
|
notifyListeners();
|
|
},
|
|
onConnectSuccess: () {
|
|
connectStatus = ConnectStatus.success;
|
|
lastConnectError = null;
|
|
notifyListeners();
|
|
},
|
|
onKickedOffline: _forceLogout,
|
|
onUserTokenExpired: _forceLogout,
|
|
onUserTokenInvalid: _forceLogout,
|
|
),
|
|
);
|
|
sdkReady = ok == true;
|
|
_setBusinessListeners();
|
|
notifyListeners();
|
|
}
|
|
|
|
void _setBusinessListeners() {
|
|
OpenIM.iMManager.conversationManager.setConversationListener(
|
|
OnConversationListener(
|
|
onSyncServerStart: (reInstall) {
|
|
syncing = true;
|
|
syncFailed = false;
|
|
notifyListeners();
|
|
},
|
|
onSyncServerFinish: (reInstall) {
|
|
syncing = false;
|
|
conversationsLoaded = true;
|
|
refreshConversations();
|
|
},
|
|
onSyncServerFailed: (reInstall) {
|
|
syncing = false;
|
|
syncFailed = true;
|
|
notifyListeners();
|
|
},
|
|
onConversationChanged: (list) => refreshConversations(),
|
|
onNewConversation: (list) => refreshConversations(),
|
|
onTotalUnreadMessageCountChanged: (count) => notifyListeners(),
|
|
),
|
|
);
|
|
OpenIM.iMManager.messageManager.setAdvancedMsgListener(
|
|
OnAdvancedMsgListener(
|
|
onRecvNewMessage: (msg) {
|
|
_newMsgController.add(msg);
|
|
},
|
|
onRecvOfflineNewMessage: (msg) {
|
|
_newMsgController.add(msg);
|
|
},
|
|
onNewRecvMessageRevoked: (info) {
|
|
if (info.clientMsgID != null) _revokeController.add(info.clientMsgID!);
|
|
},
|
|
onRecvOnlineOnlyMessage: (msg) {
|
|
// 通话信令走「仅在线」的自定义消息,转发给通话模块
|
|
if (msg.contentType == MessageType.custom) {
|
|
_signalingController.add(msg);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
// 好友与群变更只负责刷新界面(通讯录页直接监听 IMService)
|
|
OpenIM.iMManager.friendshipManager.setFriendshipListener(
|
|
OnFriendshipListener(
|
|
onFriendAdded: (info) => notifyListeners(),
|
|
onFriendDeleted: (info) => notifyListeners(),
|
|
onFriendInfoChanged: (info) => notifyListeners(),
|
|
onFriendApplicationAdded: (info) => notifyListeners(),
|
|
onFriendApplicationAccepted: (info) => notifyListeners(),
|
|
onFriendApplicationRejected: (info) => notifyListeners(),
|
|
),
|
|
);
|
|
OpenIM.iMManager.groupManager.setGroupListener(
|
|
OnGroupListener(
|
|
onJoinedGroupAdded: (info) => notifyListeners(),
|
|
onJoinedGroupDeleted: (info) => notifyListeners(),
|
|
onGroupInfoChanged: (info) => notifyListeners(),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 登录(init 之后调用)。userID/token 来自公司账号登录接口。
|
|
Future<void> login({required String userID, required String token}) async {
|
|
await init();
|
|
if (!sdkReady) {
|
|
// initSDK 返回 false:SDK 没初始化成功,直接登录会无限等待
|
|
throw StateError('消息组件初始化失败');
|
|
}
|
|
final user = await OpenIM.iMManager.login(
|
|
userID: userID,
|
|
token: token,
|
|
defaultValue: () async => UserInfo(userID: userID),
|
|
);
|
|
currentUserID = userID;
|
|
currentToken = token;
|
|
selfInfo = user;
|
|
loggedIn = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 刷新自己的资料(我的页面展示用)
|
|
Future<void> refreshSelfInfo() async {
|
|
if (!loggedIn) return;
|
|
try {
|
|
selfInfo = await OpenIM.iMManager.userManager.getSelfUserInfo();
|
|
notifyListeners();
|
|
} catch (_) {
|
|
// 拉取失败沿用内存里的旧数据
|
|
}
|
|
}
|
|
|
|
/// 退出登录
|
|
Future<void> logout() async {
|
|
try {
|
|
await OpenIM.iMManager.logout();
|
|
} catch (_) {
|
|
// 本地照常清理
|
|
}
|
|
_resetLoginState();
|
|
}
|
|
|
|
void _forceLogout() {
|
|
_resetLoginState();
|
|
onForceLogout?.call();
|
|
}
|
|
|
|
void _resetLoginState() {
|
|
loggedIn = false;
|
|
currentUserID = null;
|
|
currentToken = null;
|
|
selfInfo = null;
|
|
conversations = [];
|
|
conversationsLoaded = false;
|
|
syncing = true;
|
|
syncFailed = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
/// 重新拉取全部会话并排序(置顶在前,其余按最新消息时间倒序)
|
|
Future<void> refreshConversations() async {
|
|
if (!loggedIn) return;
|
|
try {
|
|
final list = await OpenIM.iMManager.conversationManager.getAllConversationList();
|
|
list.sort((a, b) {
|
|
final ap = a.isPinned == true ? 0 : 1;
|
|
final bp = b.isPinned == true ? 0 : 1;
|
|
if (ap != bp) return ap - bp;
|
|
return (b.latestMsgSendTime ?? 0).compareTo(a.latestMsgSendTime ?? 0);
|
|
});
|
|
conversations = list;
|
|
conversationsLoaded = true;
|
|
notifyListeners();
|
|
} catch (_) {
|
|
// 拉取失败保留现有列表
|
|
}
|
|
}
|
|
|
|
/// 进入聊天页后清除该会话未读
|
|
Future<void> markConversationRead(String conversationID) async {
|
|
try {
|
|
await OpenIM.iMManager.conversationManager.markConversationMessageAsRead(conversationID: conversationID);
|
|
} catch (_) {
|
|
// 标记失败不影响聊天
|
|
}
|
|
}
|
|
|
|
/// 解析「仅在线」自定义消息里的通话信令,不是通话信令返回 null
|
|
static SignalingPayload? parseSignaling(Message msg) {
|
|
try {
|
|
final data = msg.customElem?.data;
|
|
if (data == null || data.isEmpty) return null;
|
|
final map = jsonDecode(data) as Map<String, dynamic>;
|
|
final customType = map['customType'];
|
|
if (customType is! int || customType < 200 || customType > 204) return null;
|
|
final payload = SignalingPayload(
|
|
type: customType,
|
|
invitation: InvitationInfo.fromJson(Map<String, dynamic>.from(map['data'] ?? {})),
|
|
);
|
|
return payload;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 一条通话信令
|
|
class SignalingPayload {
|
|
/// 见 [SignalingType]
|
|
final int type;
|
|
final InvitationInfo invitation;
|
|
|
|
SignalingPayload({required this.type, required this.invitation});
|
|
|
|
/// 房间号
|
|
String? get roomID => invitation.roomID;
|
|
|
|
/// 呼叫发起人
|
|
String? get inviterUserID => invitation.inviterUserID;
|
|
}
|