From 3832e080c4cba2ec9a76e1cb5b988bc06f0fd4a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BC=96=E7=A0=81=E5=B7=A5=E7=A8=8B=E5=B8=88?= Date: Thu, 20 Aug 2026 07:58:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(mobile-next):=20=E8=BF=81=E7=A7=BB=20Andro?= =?UTF-8?q?id=20=E4=B8=80=E5=AF=B9=E4=B8=80=E8=AF=AD=E9=9F=B3=E9=80=9A?= =?UTF-8?q?=E8=AF=9D=E8=96=84=E9=80=82=E9=85=8D=E4=B8=8E=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 复用官方 OpenIMLive 信令与房间,换票改走账号服务 /api/rtc_token;入口仅音频,占线/超时/重复回调与销毁释放由公司层防护。 Co-authored-by: Cursor Co-authored-by: multica-agent --- mobile-next/lib/company/feature_flags.dart | 6 +- .../lib/company/rtc/call_permissions.dart | 13 +++ .../lib/company/rtc/call_session_guard.dart | 69 ++++++++++++ .../lib/company/rtc/livekit_call_config.dart | 21 ++++ .../lib/company/rtc/rtc_token_api.dart | 61 +++++++++++ .../lib/company/rtc/rtc_token_mapper.dart | 42 ++++++++ .../lib/core/controller/app_controller.dart | 3 + .../lib/core/controller/im_controller.dart | 100 ++++++++++++++++++ mobile-next/lib/pages/chat/chat_logic.dart | 14 +-- mobile-next/lib/pages/chat/chat_view.dart | 4 +- .../user_profile _panel_logic.dart | 14 +-- .../user_profile _panel_view.dart | 13 ++- .../lib/src/widgets/chat/chat_toolbox.dart | 2 +- .../openim_live/lib/src/live_controller.dart | 24 ++++- .../lib/src/pages/single/room.dart | 75 +++++++------ .../src/pages/single/widgets/call_state.dart | 62 +++++++++-- .../src/pages/single/widgets/controls.dart | 19 ++-- .../test/company/call_session_guard_test.dart | 65 ++++++++++++ .../test/company/rtc_token_mapper_test.dart | 71 +++++++++++++ 19 files changed, 597 insertions(+), 81 deletions(-) create mode 100644 mobile-next/lib/company/rtc/call_permissions.dart create mode 100644 mobile-next/lib/company/rtc/call_session_guard.dart create mode 100644 mobile-next/lib/company/rtc/livekit_call_config.dart create mode 100644 mobile-next/lib/company/rtc/rtc_token_api.dart create mode 100644 mobile-next/lib/company/rtc/rtc_token_mapper.dart create mode 100644 mobile-next/test/company/call_session_guard_test.dart create mode 100644 mobile-next/test/company/rtc_token_mapper_test.dart diff --git a/mobile-next/lib/company/feature_flags.dart b/mobile-next/lib/company/feature_flags.dart index a809960..77cbf0b 100644 --- a/mobile-next/lib/company/feature_flags.dart +++ b/mobile-next/lib/company/feature_flags.dart @@ -1,3 +1,5 @@ +import 'rtc/livekit_call_config.dart'; + /// 公司功能开关。关闭项直接不展示入口,不扩范围外功能。 class FeatureFlags { FeatureFlags._(); @@ -14,6 +16,6 @@ class FeatureFlags { /// 品牌迁移未取得 AGPL 书面结论前保持 false:保留 OpenIM 标识。 static const bool brandMigration = false; - /// 安卓语音通话另阶段迁移;本阶段不接入 LiveKit。 - static const bool livekitCall = false; + /// 一对一语音通话:配置齐全且薄适配落地后才打开入口。 + static bool get livekitCall => LiveKitCallConfig.enabled; } diff --git a/mobile-next/lib/company/rtc/call_permissions.dart b/mobile-next/lib/company/rtc/call_permissions.dart new file mode 100644 index 0000000..0694d18 --- /dev/null +++ b/mobile-next/lib/company/rtc/call_permissions.dart @@ -0,0 +1,13 @@ +import 'package:openim_common/openim_common.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class CallPermissions { + CallPermissions._(); + + static Future ensureMicrophone() async { + final status = await Permission.microphone.request(); + if (status.isGranted) return true; + IMViews.showToast('需要麦克风权限才能通话'); + return false; + } +} diff --git a/mobile-next/lib/company/rtc/call_session_guard.dart b/mobile-next/lib/company/rtc/call_session_guard.dart new file mode 100644 index 0000000..da4e176 --- /dev/null +++ b/mobile-next/lib/company/rtc/call_session_guard.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +/// 一对一语音会话防护:占线、超时、重复信令、销毁时释放计时器。 +/// 不自建第二套信令,只约束官方 OpenIMLive 的进出场。 +enum VoiceCallPhase { idle, outgoing, incoming, inCall } + +class CallSessionGuard { + CallSessionGuard({this.inviteTimeout = const Duration(seconds: 30)}); + + final Duration inviteTimeout; + + VoiceCallPhase phase = VoiceCallPhase.idle; + String? roomID; + void Function()? onTimeout; + + final Set _seenKeys = {}; + Timer? _timeout; + + bool get isBusy => phase != VoiceCallPhase.idle; + + bool acceptSignaling({required int customType, required String roomID}) { + if (roomID.isEmpty) return false; + return _seenKeys.add('$customType:$roomID'); + } + + String? startOutgoing(String roomID) { + if (isBusy) return 'busy'; + this.roomID = roomID; + phase = VoiceCallPhase.outgoing; + _armTimeout(); + return null; + } + + String? startIncoming(String roomID) { + if (isBusy) return 'busy'; + this.roomID = roomID; + phase = VoiceCallPhase.incoming; + _armTimeout(); + return null; + } + + void markConnected() { + if (!isBusy) return; + _timeout?.cancel(); + _timeout = null; + phase = VoiceCallPhase.inCall; + } + + bool sameRoom(String? other) => + other != null && other.isNotEmpty && other == roomID; + + void dispose() { + _timeout?.cancel(); + _timeout = null; + _seenKeys.clear(); + phase = VoiceCallPhase.idle; + roomID = null; + onTimeout = null; + } + + void _armTimeout() { + _timeout?.cancel(); + _timeout = Timer(inviteTimeout, () { + if (phase == VoiceCallPhase.outgoing || phase == VoiceCallPhase.incoming) { + onTimeout?.call(); + } + }); + } +} diff --git a/mobile-next/lib/company/rtc/livekit_call_config.dart b/mobile-next/lib/company/rtc/livekit_call_config.dart new file mode 100644 index 0000000..6ad900e --- /dev/null +++ b/mobile-next/lib/company/rtc/livekit_call_config.dart @@ -0,0 +1,21 @@ +import '../config/app_endpoints.dart'; + +/// Android 一对一语音通话配置门。入口只在配置齐全且本卡已落地时打开。 +class LiveKitCallConfig { + LiveKitCallConfig._(); + + /// 公司账号服务换票路径,对应 account-service `POST /api/rtc_token`。 + static const String rtcTokenPath = '/api/rtc_token'; + + static const Duration inviteTimeout = Duration(seconds: 30); + + /// 薄适配与构建验证通过后为 true;配置不齐时 [enabled] 仍为 false。 + static const bool shipped = true; + + static bool get endpointsReady => + AppEndpoints.livekitUrl.startsWith('ws://') && + AppEndpoints.authApiBase.startsWith('http') && + rtcTokenPath == '/api/rtc_token'; + + static bool get enabled => shipped && endpointsReady; +} diff --git a/mobile-next/lib/company/rtc/rtc_token_api.dart b/mobile-next/lib/company/rtc/rtc_token_api.dart new file mode 100644 index 0000000..62fe73a --- /dev/null +++ b/mobile-next/lib/company/rtc/rtc_token_api.dart @@ -0,0 +1,61 @@ +import 'package:dio/dio.dart'; +import 'package:openim_common/openim_common.dart'; + +import '../auth/auth_api.dart'; +import '../config/app_endpoints.dart'; +import 'livekit_call_config.dart'; +import 'rtc_token_mapper.dart'; + +/// 公司账号服务换 LiveKit 进房 token。不改官方 SDK 核心,也不改现网接口。 +class RtcTokenApi { + RtcTokenApi({Dio? dio}) + : _dio = dio ?? + Dio(BaseOptions( + baseUrl: AppEndpoints.authApiBase, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + )); + + final Dio _dio; + + Future getToken({ + required String room, + required String identity, + required String authToken, + }) async { + if (room.isEmpty || identity.isEmpty) { + throw AuthException('通话数据不完整'); + } + if (authToken.isEmpty) { + throw AuthException('登录状态已失效,请重新登录'); + } + try { + final resp = await _dio.post( + LiveKitCallConfig.rtcTokenPath, + data: {'room': room, 'identity': identity}, + options: Options(headers: {'Authorization': 'Bearer $authToken'}), + ); + final cred = RtcTokenMapper.parse( + resp.data, + fallbackLiveUrl: AppEndpoints.livekitUrl, + ); + return SignalingCertificate( + token: cred.token, + roomID: room, + liveURL: cred.liveURL, + ); + } on DioException catch (e) { + final data = e.response?.data; + if (data is Map && data['msg'] != null && data['msg'].toString().isNotEmpty) { + throw AuthException(data['msg'].toString()); + } + throw AuthException('连不上语音服务,请检查网络', network: true); + } on AuthException { + rethrow; + } on FormatException catch (e) { + throw AuthException(e.message); + } catch (_) { + throw AuthException('发起通话失败,请稍后再试'); + } + } +} diff --git a/mobile-next/lib/company/rtc/rtc_token_mapper.dart b/mobile-next/lib/company/rtc/rtc_token_mapper.dart new file mode 100644 index 0000000..c61e878 --- /dev/null +++ b/mobile-next/lib/company/rtc/rtc_token_mapper.dart @@ -0,0 +1,42 @@ +/// 把账号服务 `/api/rtc_token` 的响应收成进房凭证。 +/// +/// 现网成功体:`{ code: 0, data: { token } }`,可选 `liveURL` / `serverUrl`。 +/// 缺少直播地址时由调用方填 [fallbackLiveUrl],不得改服务端。 +class CompanyRtcCredential { + const CompanyRtcCredential({required this.token, required this.liveURL}); + + final String token; + final String liveURL; +} + +class RtcTokenMapper { + RtcTokenMapper._(); + + static CompanyRtcCredential parse( + dynamic body, { + required String fallbackLiveUrl, + }) { + if (body is! Map) { + throw const FormatException('语音通话凭证格式不正确'); + } + final map = Map.from(body); + final code = map['code']; + if (code != null && code != 0) { + final msg = map['msg']?.toString() ?? ''; + throw FormatException(msg.isNotEmpty ? msg : '获取通话凭证失败'); + } + final data = map['data']; + final payload = data is Map ? Map.from(data) : map; + final token = payload['token']?.toString() ?? ''; + if (token.isEmpty) { + throw const FormatException('服务器未返回通话凭证'); + } + final live = payload['liveURL']?.toString() ?? + payload['serverUrl']?.toString() ?? + ''; + return CompanyRtcCredential( + token: token, + liveURL: live.isNotEmpty ? live : fallbackLiveUrl, + ); + } +} diff --git a/mobile-next/lib/core/controller/app_controller.dart b/mobile-next/lib/core/controller/app_controller.dart index 88f104b..e665a36 100644 --- a/mobile-next/lib/core/controller/app_controller.dart +++ b/mobile-next/lib/core/controller/app_controller.dart @@ -59,6 +59,9 @@ class AppController extends GetxController with UpgradeManger { if (isRunningBackground && !run) {} isRunningBackground = run; + if (Get.isRegistered()) { + Get.find().backgroundSubject.add(run); + } if (!run) { _cancelAllNotifications(); } diff --git a/mobile-next/lib/core/controller/im_controller.dart b/mobile-next/lib/core/controller/im_controller.dart index f2c5038..1a62329 100644 --- a/mobile-next/lib/core/controller/im_controller.dart +++ b/mobile-next/lib/core/controller/im_controller.dart @@ -9,19 +9,113 @@ import 'package:openim_common/openim_common.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:openim_live/openim_live.dart'; +import '../../company/feature_flags.dart'; +import '../../company/rtc/call_permissions.dart'; +import '../../company/rtc/call_session_guard.dart'; +import '../../company/rtc/rtc_token_api.dart'; import '../im_callback.dart'; class IMController extends GetxController with IMCallback, OpenIMLive { late Rx userInfo; late String atAllTag; + final _rtcTokenApi = RtcTokenApi(); + final callGuard = CallSessionGuard(); @override void onClose() { + callGuard.dispose(); super.close(); onCloseLive(); super.onClose(); } + @override + Future getRtcCertificate(String roomID, String userID) { + final token = DataSp.imToken; + if (token == null || token.isEmpty) { + return Future.error(StateError('登录状态已失效,请重新登录')); + } + return _rtcTokenApi.getToken(room: roomID, identity: userID, authToken: token); + } + + @override + void onLiveSessionClosed() { + callGuard.dispose(); + } + + @override + void onLiveConnected() { + callGuard.markConnected(); + } + + @override + void receiveNewInvitation(SignalingInfo info) { + if (!FeatureFlags.livekitCall) return; + final invitation = info.invitation; + final roomID = invitation?.roomID ?? ''; + if (invitation?.mediaType != 'audio' || invitation?.sessionType != ConversationType.single) { + onTapReject(info); + return; + } + if (!callGuard.acceptSignaling(customType: CustomMessageType.callingInvite, roomID: roomID)) { + return; + } + if (isBusy || callGuard.isBusy) { + onTapReject(info); + return; + } + if (callGuard.startIncoming(roomID) != null) { + onTapReject(info); + return; + } + super.receiveNewInvitation(info); + } + + @override + void call({ + required CallObj callObj, + required CallType callType, + CallState callState = CallState.call, + String? roomID, + String? inviterUserID, + required List inviteeUserIDList, + String? groupID, + SignalingCertificate? credentials, + }) async { + if (!FeatureFlags.livekitCall) return; + if (callType != CallType.audio || callObj != CallObj.single) { + IMViews.showToast('暂只支持一对一语音通话'); + return; + } + if (isBusy || callGuard.isBusy) { + IMViews.showToast(StrRes.callingBusy); + return; + } + final granted = await CallPermissions.ensureMicrophone(); + if (!granted) return; + callGuard.startOutgoing(roomID ?? ''); + super.call( + callObj: callObj, + callType: callType, + callState: callState, + roomID: roomID, + inviterUserID: inviterUserID, + inviteeUserIDList: inviteeUserIDList, + groupID: groupID, + credentials: credentials, + ); + } + + @override + Future onTapPickup(SignalingInfo signaling) async { + final granted = await CallPermissions.ensureMicrophone(); + if (!granted) { + await onTapReject(signaling); + return Future.error(StateError('microphone denied')); + } + return super.onTapPickup(signaling); + } + @override void onInit() async { super.onInit(); @@ -84,8 +178,14 @@ class IMController extends GetxController with IMCallback, OpenIMLive { customType == CustomMessageType.callingReject || customType == CustomMessageType.callingCancel || customType == CustomMessageType.callingHungup) { + if (!FeatureFlags.livekitCall) return; final signaling = SignalingInfo(invitation: InvitationInfo.fromJson(map['data'])); signaling.userID = signaling.invitation?.inviterUserID; + final roomID = signaling.invitation?.roomID ?? ''; + if (customType != CustomMessageType.callingInvite && + !callGuard.acceptSignaling(customType: customType as int, roomID: roomID)) { + return; + } switch (customType) { case CustomMessageType.callingInvite: diff --git a/mobile-next/lib/pages/chat/chat_logic.dart b/mobile-next/lib/pages/chat/chat_logic.dart index 725d27f..503a07e 100644 --- a/mobile-next/lib/pages/chat/chat_logic.dart +++ b/mobile-next/lib/pages/chat/chat_logic.dart @@ -22,6 +22,7 @@ import 'package:wechat_assets_picker/wechat_assets_picker.dart'; import 'package:wechat_camera_picker/wechat_camera_picker.dart'; import 'package:openim_live/openim_live.dart'; +import '../../company/feature_flags.dart'; import '../../core/controller/app_controller.dart'; import '../../core/controller/im_controller.dart'; import '../../core/im_callback.dart'; @@ -1622,18 +1623,17 @@ class ChatLogic extends SuperController { } void call() { + if (!FeatureFlags.livekitCall || !isSingleChat) return; if (rtcIsBusy) { IMViews.showToast(StrRes.callingBusy); return; } - IMViews.openIMCallSheet(nickname.value, (index) { - imLogic.call( - callObj: CallObj.single, - callType: index == 0 ? CallType.audio : CallType.video, - inviteeUserIDList: [if (isSingleChat) userID!], - ); - }); + imLogic.call( + callObj: CallObj.single, + callType: CallType.audio, + inviteeUserIDList: [userID!], + ); } void onScrollToTop() { diff --git a/mobile-next/lib/pages/chat/chat_view.dart b/mobile-next/lib/pages/chat/chat_view.dart index b474545..0cd84e2 100644 --- a/mobile-next/lib/pages/chat/chat_view.dart +++ b/mobile-next/lib/pages/chat/chat_view.dart @@ -225,7 +225,7 @@ class ChatPage extends StatelessWidget { onCloseMultiModel: logic.exit, onClickMoreBtn: logic.chatSetup, onClickCallBtn: logic.call, - showCallBtn: FeatureFlags.livekitCall, + showCallBtn: FeatureFlags.livekitCall && logic.isSingleChat, ), body: SafeArea( child: WaterMarkBgView( @@ -244,7 +244,7 @@ class ChatPage extends StatelessWidget { toolbox: ChatToolBox( onTapAlbum: logic.onTapAlbum, onTapCamera: logic.onTapCamera, - onTapCall: logic.call, + onTapCall: FeatureFlags.livekitCall && logic.isSingleChat ? logic.call : null, onTapCard: logic.onTapCarte, onTapFile: logic.onTapFile, onTapLocation: logic.onTapLocation, diff --git a/mobile-next/lib/pages/contacts/user_profile_panel/user_profile _panel_logic.dart b/mobile-next/lib/pages/contacts/user_profile_panel/user_profile _panel_logic.dart index 4fc164e..39b408b 100644 --- a/mobile-next/lib/pages/contacts/user_profile_panel/user_profile _panel_logic.dart +++ b/mobile-next/lib/pages/contacts/user_profile_panel/user_profile _panel_logic.dart @@ -6,6 +6,7 @@ import 'package:extended_image/extended_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; import 'package:get/get.dart'; +import 'package:openim/company/feature_flags.dart'; import 'package:openim/routes/app_navigator.dart'; import 'package:openim_common/openim_common.dart'; import 'package:sprintf/sprintf.dart'; @@ -324,13 +325,12 @@ class UserProfilePanelLogic extends GetxController { } void toCall() { - IMViews.openIMCallSheet(userInfo.value.showName, (index) { - imLogic.call( - callObj: CallObj.single, - callType: index == 0 ? CallType.audio : CallType.video, - inviteeUserIDList: [userInfo.value.userID!], - ); - }); + if (!FeatureFlags.livekitCall) return; + imLogic.call( + callObj: CallObj.single, + callType: CallType.audio, + inviteeUserIDList: [userInfo.value.userID!], + ); } void copyID() { diff --git a/mobile-next/lib/pages/contacts/user_profile_panel/user_profile _panel_view.dart b/mobile-next/lib/pages/contacts/user_profile_panel/user_profile _panel_view.dart index 48b8c2a..af65c2b 100644 --- a/mobile-next/lib/pages/contacts/user_profile_panel/user_profile _panel_view.dart +++ b/mobile-next/lib/pages/contacts/user_profile_panel/user_profile _panel_view.dart @@ -7,6 +7,7 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:openim_common/openim_common.dart'; +import '../../../company/feature_flags.dart'; import 'user_profile _panel_logic.dart'; class UserProfilePanelPage extends StatelessWidget { @@ -234,12 +235,14 @@ class UserProfilePanelPage extends StatelessWidget { height: 108.h, child: Row( children: [ - Expanded( - child: ImageTextButton.call( - onTap: logic.toCall, + if (FeatureFlags.livekitCall) ...[ + Expanded( + child: ImageTextButton.call( + onTap: logic.toCall, + ), ), - ), - 11.horizontalSpace, + 11.horizontalSpace, + ], Expanded( child: ImageTextButton.message( onTap: logic.toChat, diff --git a/mobile-next/openim_common/lib/src/widgets/chat/chat_toolbox.dart b/mobile-next/openim_common/lib/src/widgets/chat/chat_toolbox.dart index 734cf12..8477f18 100644 --- a/mobile-next/openim_common/lib/src/widgets/chat/chat_toolbox.dart +++ b/mobile-next/openim_common/lib/src/widgets/chat/chat_toolbox.dart @@ -38,7 +38,7 @@ class ChatToolBox extends StatelessWidget { ToolboxItemInfo( text: StrRes.toolboxCall, icon: ImageRes.toolboxCall, - onTap: () => Permissions.cameraAndMicrophone(onTapCall), + onTap: () => Permissions.microphone(onTapCall), ), ToolboxItemInfo( text: StrRes.toolboxFile, diff --git a/mobile-next/openim_live/lib/src/live_controller.dart b/mobile-next/openim_live/lib/src/live_controller.dart index 399b947..1a689aa 100644 --- a/mobile-next/openim_live/lib/src/live_controller.dart +++ b/mobile-next/openim_live/lib/src/live_controller.dart @@ -134,6 +134,11 @@ mixin OpenIMLive { ), onError: onError, onRoomDisconnected: () => onRoomDisconnected(event.data), + onStartCalling: onLiveConnected, + onClose: () { + _stopSound(); + onLiveSessionClosed(); + }, ); } else if (event.state == CallState.beRejected) { insertSignalingMessageSubject.add(event); @@ -224,10 +229,14 @@ mixin OpenIMLive { onBusyLine: onBusyLine, onStartCalling: () { _stopSound(); + onLiveConnected(); }, onError: onError, onRoomDisconnected: () => onRoomDisconnected(signal), - onClose: _stopSound, + onClose: () { + _stopSound(); + onLiveSessionClosed(); + }, ); } @@ -255,11 +264,20 @@ mixin OpenIMLive { offlinePushInfo: OfflinePushInfo(), userID: signaling.invitation!.inviteeUserIDList!.first, isOnlineOnly: true); - final certificate = await Apis.getTokenForRTC(signaling.invitation!.roomID!, OpenIM.iMManager.userID); + final certificate = await getRtcCertificate(signaling.invitation!.roomID!, OpenIM.iMManager.userID); return certificate; } + /// 公司层可覆盖:走账号服务 `/api/rtc_token`,默认仍走官方 chat 换票。 + Future getRtcCertificate(String roomID, String userID) { + return Apis.getTokenForRTC(roomID, userID); + } + + void onLiveSessionClosed() {} + + void onLiveConnected() {} + Future onTapPickup(SignalingInfo signaling) async { _beCalledEvent = null; // ios bug _autoPickup = false; @@ -272,7 +290,7 @@ mixin OpenIMLive { offlinePushInfo: OfflinePushInfo(), userID: signaling.invitation!.inviterUserID, isOnlineOnly: true); - final certificate = await Apis.getTokenForRTC(signaling.invitation!.roomID!, OpenIM.iMManager.userID); + final certificate = await getRtcCertificate(signaling.invitation!.roomID!, OpenIM.iMManager.userID); return certificate; } diff --git a/mobile-next/openim_live/lib/src/pages/single/room.dart b/mobile-next/openim_live/lib/src/pages/single/room.dart index 0ba1aaa..7ac6a34 100644 --- a/mobile-next/openim_live/lib/src/pages/single/room.dart +++ b/mobile-next/openim_live/lib/src/pages/single/room.dart @@ -43,50 +43,59 @@ class _SingleRoomViewState extends SignalState { @override void dispose() { - // always dispose listener - (() async { - _room?.removeListener(_onRoomDidUpdate); - await _listener?.dispose(); - await _room?.remoteParticipants.values.firstOrNull?.dispose(); - await _room?.localParticipant?.dispose(); - await _room?.disconnect(); - await _room?.dispose(); - })(); + final room = _room; + final listener = _listener; + _room = null; + _listener = null; + room?.removeListener(_onRoomDidUpdate); + () async { + await listener?.dispose(); + await room?.remoteParticipants.values.firstOrNull?.dispose(); + await room?.localParticipant?.dispose(); + await room?.disconnect(); + await room?.dispose(); + }(); super.dispose(); } @override Future connect() async { - final url = certificate.liveURL!; - final token = certificate.token!; + final url = certificate.liveURL; + final token = certificate.token; + if (url == null || url.isEmpty || token == null || token.isEmpty) { + widget.onError?.call(StateError('missing livekit credential'), StackTrace.current); + widget.onClose?.call(); + return; + } final busyLineUsers = certificate.busyLineUserIDList ?? []; if (busyLineUsers.isNotEmpty) { widget.onBusyLine?.call(); widget.onClose?.call(); return; } - // Try to connect to a room - // This will throw an Exception if it fails for any reason. try { - //create new room - _room = Room(); + _room = Room( + roomOptions: const RoomOptions( + dynacast: true, + adaptiveStream: true, + defaultCameraCaptureOptions: CameraCaptureOptions(params: VideoParametersPresets.h720_169), + defaultVideoPublishOptions: VideoPublishOptions( + simulcast: true, + videoCodec: 'VP9', + videoEncoding: VideoEncoding( + maxBitrate: 5 * 1000 * 1000, + maxFramerate: 15, + ), + ), + ), + ); - // Create a Listener before connecting _listener = _room?.createListener(); - // Try to connect to the room - // This will throw an Exception if it fails for any reason. - await _room?.connect(url, token, - roomOptions: RoomOptions( - dynacast: true, - adaptiveStream: true, - defaultCameraCaptureOptions: const CameraCaptureOptions(params: VideoParametersPresets.h720_169), - defaultVideoPublishOptions: VideoPublishOptions( - simulcast: true, - videoCodec: 'VP9', - videoEncoding: const VideoEncoding( - maxBitrate: 5 * 1000 * 1000, - maxFramerate: 15, - )))); + await _room?.connect( + url, + token, + connectOptions: const ConnectOptions(autoSubscribe: true), + ); if (!mounted) return; _room?.addListener(_onRoomDidUpdate); if (null != _listener) _setUpListeners(); @@ -111,6 +120,12 @@ class _SingleRoomViewState extends SignalState { widget.onClose?.call(); }); }) + ..on((event) { + IMViews.showToast('通话重连中'); + }) + ..on((event) { + IMViews.showToast('通话已恢复'); + }) ..on((event) {}) ..on((_) => _sortParticipants()) ..on((_) => _sortParticipants()) diff --git a/mobile-next/openim_live/lib/src/pages/single/widgets/call_state.dart b/mobile-next/openim_live/lib/src/pages/single/widgets/call_state.dart index 86e2f8b..ccee282 100644 --- a/mobile-next/openim_live/lib/src/pages/single/widgets/call_state.dart +++ b/mobile-next/openim_live/lib/src/pages/single/widgets/call_state.dart @@ -70,6 +70,7 @@ abstract class SignalState extends State { int duration = 0; bool enabledMicrophone = true; bool enabledSpeaker = true; + Timer? _inviteTimeout; ParticipantTrack? remoteParticipantTrack; ParticipantTrack? localParticipantTrack; @@ -82,11 +83,14 @@ abstract class SignalState extends State { widget.onSyncUserInfo?.call(widget.userID).then(_onUpdateUserInfo); onDail(); autoPickup(); + _armInviteTimeout(); super.initState(); } @override void dispose() { + _inviteTimeout?.cancel(); + _inviteTimeout = null; callStateSubject.close(); callEventSub?.cancel(); super.dispose(); @@ -116,14 +120,17 @@ abstract class SignalState extends State { } if (event.state == CallState.beRejected || event.state == CallState.beCanceled) { + _cancelInviteTimeout(); widget.onClose?.call(); } else if (event.state == CallState.otherReject || event.state == CallState.otherAccepted) { if (existParticipants()) { return; } + _cancelInviteTimeout(); widget.onClose?.call(); IMViews.showToast(sprintf(StrRes.otherCallHandle, [event.state == CallState.otherReject ? StrRes.rejectCall : StrRes.accept])); } else if (event.state == CallState.timeout) { + _cancelInviteTimeout(); widget.onClose?.call(); } else if (event.state == CallState.beAccepted) { // 邀请对象比发起对象提前进入房间 @@ -134,6 +141,7 @@ abstract class SignalState extends State { } onParticipantConnected() { + _cancelInviteTimeout(); callStateSubject.add(CallState.calling); widget.onStartCalling?.call(); } @@ -145,10 +153,13 @@ abstract class SignalState extends State { /// 发起者在对方为进入房间都是 等待状态 onDail() async { if (widget.initState == CallState.call) { - // callStateSubject.add(CallState.connecting); - certificate = await widget.onDial!.call(); - widget.onBindRoomID?.call(roomID = certificate.roomID!); - await connect(); + try { + certificate = await widget.onDial!.call(); + widget.onBindRoomID?.call(roomID = certificate.roomID!); + await connect(); + } catch (error, stackTrace) { + widget.onError?.call(error, stackTrace); + } } } @@ -160,25 +171,33 @@ abstract class SignalState extends State { onTapPickup() async { Logger.print('------------onTapPickup---------连接中--------'); - callStateSubject.add(CallState.connecting); - certificate = await widget.onTapPickup!.call(); - widget.onBindRoomID?.call(roomID = certificate.roomID!); - await connect(); - callStateSubject.add(CallState.calling); - widget.onStartCalling?.call(); - Logger.print('------------onTapPickup---------连接成功--------'); + try { + callStateSubject.add(CallState.connecting); + certificate = await widget.onTapPickup!.call(); + widget.onBindRoomID?.call(roomID = certificate.roomID!); + await connect(); + _cancelInviteTimeout(); + callStateSubject.add(CallState.calling); + widget.onStartCalling?.call(); + Logger.print('------------onTapPickup---------连接成功--------'); + } catch (error, stackTrace) { + widget.onError?.call(error, stackTrace); + } } /// [isPositive] 人为挂断行为 onTapHangup(bool isPositive) async { + _cancelInviteTimeout(); await widget.onTapHangup?.call(duration, isPositive).whenComplete(() => /*isPositive ? {} : */ widget.onClose?.call()); } onTapCancel() async { + _cancelInviteTimeout(); await widget.onTapCancel?.call().whenComplete(() => widget.onClose?.call()); } onTapReject() async { + _cancelInviteTimeout(); await widget.onTapReject?.call().whenComplete(() => widget.onClose?.call()); } @@ -227,6 +246,27 @@ abstract class SignalState extends State { bool smallScreenIsRemote = true; + void _armInviteTimeout() { + if (widget.initState != CallState.call && widget.initState != CallState.beCalled) { + return; + } + _inviteTimeout?.cancel(); + _inviteTimeout = Timer(const Duration(seconds: 30), () async { + if (!mounted) return; + if (callState == CallState.calling) return; + if (widget.initState == CallState.call) { + await onTapCancel(); + } else { + await onTapReject(); + } + }); + } + + void _cancelInviteTimeout() { + _inviteTimeout?.cancel(); + _inviteTimeout = null; + } + @override Widget build(BuildContext context) => Stack( children: [ diff --git a/mobile-next/openim_live/lib/src/pages/single/widgets/controls.dart b/mobile-next/openim_live/lib/src/pages/single/widgets/controls.dart index bf9d3d1..dbda274 100644 --- a/mobile-next/openim_live/lib/src/pages/single/widgets/controls.dart +++ b/mobile-next/openim_live/lib/src/pages/single/widgets/controls.dart @@ -4,24 +4,15 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_openim_sdk/flutter_openim_sdk.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:livekit_client/livekit_client.dart'; -import 'package:openim_common/openim_common.dart'; -import 'package:openim_live/src/widgets/live_button.dart'; -import 'package:synchronized/synchronized.dart'; - -import '../../../live_client.dart'; -import '../../../widgets/loading_view.dart'; - -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:openim_common/openim_common.dart'; import 'package:openim_live/src/widgets/live_button.dart'; +import 'package:openim_live/src/widgets/loading_view.dart'; import 'package:synchronized/synchronized.dart'; import '../../../live_client.dart'; + class ControlsView extends StatefulWidget { const ControlsView({ Key? key, @@ -82,8 +73,8 @@ class _ControlsViewState extends State { /// 默认启用麦克风 bool _enabledMicrophone = true; - /// 默认开启扬声器 - bool _enabledSpeaker = true; + /// 语音默认听筒,视频默认扬声器 + late bool _enabledSpeaker; final _lockAudio = Lock(); final _lockSpeaker = Lock(); @@ -100,6 +91,7 @@ class _ControlsViewState extends State { @override void initState() { + _enabledSpeaker = widget.callType != CallType.audio; _onChangedCallState(widget.initState); _callStateChangedSub = widget.callStateStream.listen(_onChangedCallState); _roomDidUpdateSub = widget.roomDidUpdateStream.listen(_roomDidUpdate); @@ -116,6 +108,7 @@ class _ControlsViewState extends State { _participant = room.localParticipant; _participant?.addListener(_onChange); } + Hardware.instance.setSpeakerphoneOn(_enabledSpeaker); } _onChangedCallState(CallState state) { diff --git a/mobile-next/test/company/call_session_guard_test.dart b/mobile-next/test/company/call_session_guard_test.dart new file mode 100644 index 0000000..dfcd0aa --- /dev/null +++ b/mobile-next/test/company/call_session_guard_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openim/company/rtc/call_session_guard.dart'; +import 'package:openim/company/rtc/livekit_call_config.dart'; + +void main() { + test('配置门:内网地址与换票路径齐全时才打开入口', () { + expect(LiveKitCallConfig.rtcTokenPath, '/api/rtc_token'); + expect(LiveKitCallConfig.endpointsReady, isTrue); + expect(LiveKitCallConfig.enabled, LiveKitCallConfig.shipped); + }); + + test('占线时拒绝第二路呼出/呼入', () { + final guard = CallSessionGuard(); + expect(guard.startOutgoing('room-a'), isNull); + expect(guard.startOutgoing('room-b'), 'busy'); + expect(guard.startIncoming('room-c'), 'busy'); + guard.dispose(); + expect(guard.isBusy, isFalse); + expect(guard.startIncoming('room-d'), isNull); + guard.dispose(); + }); + + test('同一房间同一信令只处理一次', () { + final guard = CallSessionGuard(); + expect(guard.acceptSignaling(customType: 200, roomID: 'r1'), isTrue); + expect(guard.acceptSignaling(customType: 200, roomID: 'r1'), isFalse); + expect(guard.acceptSignaling(customType: 201, roomID: 'r1'), isTrue); + expect(guard.acceptSignaling(customType: 200, roomID: ''), isFalse); + guard.dispose(); + }); + + test('接听后取消超时,销毁后释放计时器', () async { + var timedOut = false; + final guard = CallSessionGuard(inviteTimeout: const Duration(milliseconds: 40)); + guard.onTimeout = () => timedOut = true; + guard.startOutgoing('room-t'); + guard.markConnected(); + await Future.delayed(const Duration(milliseconds: 80)); + expect(timedOut, isFalse); + expect(guard.phase, VoiceCallPhase.inCall); + + guard.dispose(); + expect(guard.phase, VoiceCallPhase.idle); + expect(guard.roomID, isNull); + }); + + test('呼出超时回调', () async { + var timedOut = false; + final guard = CallSessionGuard(inviteTimeout: const Duration(milliseconds: 30)); + guard.onTimeout = () => timedOut = true; + guard.startOutgoing('room-t'); + await Future.delayed(const Duration(milliseconds: 80)); + expect(timedOut, isTrue); + guard.dispose(); + }); + + test('sameRoom 只认当前房间', () { + final guard = CallSessionGuard(); + guard.startIncoming('room-1'); + expect(guard.sameRoom('room-1'), isTrue); + expect(guard.sameRoom('room-2'), isFalse); + expect(guard.sameRoom(null), isFalse); + guard.dispose(); + }); +} diff --git a/mobile-next/test/company/rtc_token_mapper_test.dart b/mobile-next/test/company/rtc_token_mapper_test.dart new file mode 100644 index 0000000..7bdaf96 --- /dev/null +++ b/mobile-next/test/company/rtc_token_mapper_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:openim/company/rtc/rtc_token_mapper.dart'; + +void main() { + const fallback = 'ws://192.168.200.11:17880'; + + test('解析账号服务 token,缺 liveURL 时回落到配置地址', () { + final cred = RtcTokenMapper.parse( + { + 'code': 0, + 'msg': '', + 'data': {'token': 'lk-token'}, + }, + fallbackLiveUrl: fallback, + ); + expect(cred.token, 'lk-token'); + expect(cred.liveURL, fallback); + }); + + test('兼容官方 serverUrl 字段', () { + final cred = RtcTokenMapper.parse( + { + 'code': 0, + 'data': {'token': 't', 'serverUrl': 'ws://live.example:7880'}, + }, + fallbackLiveUrl: fallback, + ); + expect(cred.liveURL, 'ws://live.example:7880'); + }); + + test('优先使用 liveURL', () { + final cred = RtcTokenMapper.parse( + { + 'data': { + 'token': 't', + 'liveURL': 'ws://a:1', + 'serverUrl': 'ws://b:2', + }, + }, + fallbackLiveUrl: fallback, + ); + expect(cred.liveURL, 'ws://a:1'); + }); + + test('业务失败码抛出服务端文案', () { + expect( + () => RtcTokenMapper.parse( + {'code': 1, 'msg': '语音通话服务未配置,请联系管理员'}, + fallbackLiveUrl: fallback, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + '语音通话服务未配置,请联系管理员', + )), + ); + }); + + test('缺 token 失败', () { + expect( + () => RtcTokenMapper.parse( + { + 'code': 0, + 'data': {'token': ''}, + }, + fallbackLiveUrl: fallback, + ), + throwsA(isA()), + ); + }); +}