feat(mobile-next): 迁移 Android 一对一语音通话薄适配与入口
复用官方 OpenIMLive 信令与房间,换票改走账号服务 /api/rtc_token;入口仅音频,占线/超时/重复回调与销毁释放由公司层防护。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
Cursor
multica-agent
parent
2a817e77d8
commit
3832e080c4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import 'package:openim_common/openim_common.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class CallPermissions {
|
||||
CallPermissions._();
|
||||
|
||||
static Future<bool> ensureMicrophone() async {
|
||||
final status = await Permission.microphone.request();
|
||||
if (status.isGranted) return true;
|
||||
IMViews.showToast('需要麦克风权限才能通话');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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<String> _seenKeys = <String>{};
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<SignalingCertificate> 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('发起通话失败,请稍后再试');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic>.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<String, dynamic>.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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,9 @@ class AppController extends GetxController with UpgradeManger {
|
||||
|
||||
if (isRunningBackground && !run) {}
|
||||
isRunningBackground = run;
|
||||
if (Get.isRegistered<IMController>()) {
|
||||
Get.find<IMController>().backgroundSubject.add(run);
|
||||
}
|
||||
if (!run) {
|
||||
_cancelAllNotifications();
|
||||
}
|
||||
|
||||
@@ -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<UserFullInfo> userInfo;
|
||||
late String atAllTag;
|
||||
final _rtcTokenApi = RtcTokenApi();
|
||||
final callGuard = CallSessionGuard();
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
callGuard.dispose();
|
||||
super.close();
|
||||
onCloseLive();
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SignalingCertificate> 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<String> 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<SignalingCertificate> 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:
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user