This commit is contained in:
roxy 2026-05-09 18:15:32 +08:00
parent c810e645dc
commit 53dbacef91
12 changed files with 484 additions and 24 deletions

View File

@ -9,6 +9,10 @@
<uses-permission android:name="android.permission.READ_PHONE_STATE" /> <uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" /> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" /> <uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
@ -130,6 +134,12 @@
android:name="io.agora.rtc2.extensions.MediaProjectionMgr$LocalScreenSharingService" android:name="io.agora.rtc2.extensions.MediaProjectionMgr$LocalScreenSharingService"
tools:node="remove" /> tools:node="remove" />
<service
android:name=".VoiceRoomForegroundService"
android:exported="false"
android:foregroundServiceType="mediaPlayback|microphone"
android:stopWithTask="false" />
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_channel_id" android:name="com.google.firebase.messaging.default_notification_channel_id"
android:value="high_importance_channel" /> android:value="high_importance_channel" />

View File

@ -30,6 +30,33 @@ class MainActivity : FlutterActivity() {
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
VOICE_ROOM_FOREGROUND_CHANNEL
).setMethodCallHandler { call, result ->
when (call.method) {
"start" -> {
try {
VoiceRoomForegroundService.start(
applicationContext,
call.argument<String>("roomName")
)
result.success(null)
} catch (e: Exception) {
result.error("VOICE_ROOM_FOREGROUND_START_FAILED", e.message, null)
}
}
"stop" -> {
try {
VoiceRoomForegroundService.stop(applicationContext)
result.success(null)
} catch (e: Exception) {
result.error("VOICE_ROOM_FOREGROUND_STOP_FAILED", e.message, null)
}
}
else -> result.notImplemented()
}
}
} }
override fun onDestroy() { override fun onDestroy() {
@ -90,5 +117,7 @@ class MainActivity : FlutterActivity() {
companion object { companion object {
private const val INSTALL_REFERRER_CHANNEL = "com.org.yumiparty/install_referrer" private const val INSTALL_REFERRER_CHANNEL = "com.org.yumiparty/install_referrer"
private const val MOBILE_CONTEXT_CHANNEL = "com.org.yumiparty/mobile_context" private const val MOBILE_CONTEXT_CHANNEL = "com.org.yumiparty/mobile_context"
private const val VOICE_ROOM_FOREGROUND_CHANNEL =
"com.org.yumiparty/voice_room_foreground_service"
} }
} }

View File

@ -0,0 +1,131 @@
package com.org.yumiparty
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
class VoiceRoomForegroundService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
ensureNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val roomName = intent?.getStringExtra(EXTRA_ROOM_NAME)
?.trim()
?.takeIf { it.isNotEmpty() }
?: DEFAULT_ROOM_NAME
startAsForeground(roomName)
return START_NOT_STICKY
}
private fun startAsForeground(roomName: String) {
val notification = buildNotification(roomName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val serviceType =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
} else {
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
}
startForeground(
NOTIFICATION_ID,
notification,
serviceType
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
@Suppress("DEPRECATION")
private fun buildNotification(roomName: String): Notification {
val launchIntent = (
packageManager.getLaunchIntentForPackage(packageName)
?: Intent(this, MainActivity::class.java)
).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
val pendingIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or immutableFlag()
)
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
Notification.Builder(this)
}
return builder
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle("Yumi voice room")
.setContentText(roomName)
.setOngoing(true)
.setShowWhen(false)
.setOnlyAlertOnce(true)
.setPriority(Notification.PRIORITY_LOW)
.setCategory(Notification.CATEGORY_CALL)
.setContentIntent(pendingIntent)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
return
}
val channel = NotificationChannel(
CHANNEL_ID,
"Voice room",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Keeps voice rooms active while Yumi is in the background."
setShowBadge(false)
}
manager.createNotificationChannel(channel)
}
private fun immutableFlag(): Int =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE
} else {
0
}
companion object {
private const val CHANNEL_ID = "yumi_voice_room"
private const val NOTIFICATION_ID = 24031
private const val EXTRA_ROOM_NAME = "room_name"
private const val DEFAULT_ROOM_NAME = "Voice room is running"
fun start(context: Context, roomName: String?) {
val intent = Intent(context, VoiceRoomForegroundService::class.java)
.putExtra(EXTRA_ROOM_NAME, roomName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
context.stopService(Intent(context, VoiceRoomForegroundService::class.java))
}
}
}

View File

@ -502,7 +502,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = S9X2AJ2US9; DEVELOPMENT_TEAM = S9X2AJ2US9;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -693,7 +693,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerDebug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = F33K8VUZ62; DEVELOPMENT_TEAM = F33K8VUZ62;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -722,7 +722,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerRelease.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = F33K8VUZ62; DEVELOPMENT_TEAM = F33K8VUZ62;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

View File

@ -15,6 +15,7 @@ import 'package:yumi/shared/data_sources/sources/repositories/sc_room_repository
import 'package:yumi/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart'; import 'package:yumi/shared/data_sources/sources/repositories/sc_vip_repository_imp.dart';
import 'package:yumi/services/room/rc_room_manager.dart'; import 'package:yumi/services/room/rc_room_manager.dart';
import 'package:yumi/services/audio/rtm_manager.dart'; import 'package:yumi/services/audio/rtm_manager.dart';
import 'package:yumi/services/audio/voice_room_foreground_service.dart';
import 'package:yumi/services/auth/user_profile_manager.dart'; import 'package:yumi/services/auth/user_profile_manager.dart';
import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart'; import 'package:yumi/ui_kit/components/sc_rotating_dots_loading.dart';
import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart';
@ -302,6 +303,11 @@ class RealTimeCommunicationManager extends ChangeNotifier {
'roomStartupSeatInteractionLoading'; 'roomStartupSeatInteractionLoading';
static const String _selfMicGoUpRetryLoadingTag = 'selfMicGoUpRetryLoading'; static const String _selfMicGoUpRetryLoadingTag = 'selfMicGoUpRetryLoading';
static const int _selfMicGoUpFailureLimit = 3; static const int _selfMicGoUpFailureLimit = 3;
static const List<Duration> _roomRedPacketEntryRefreshDelays = [
Duration.zero,
Duration(seconds: 2),
Duration(seconds: 6),
];
bool needUpDataUserInfo = false; bool needUpDataUserInfo = false;
bool _roomVisualEffectsEnabled = false; bool _roomVisualEffectsEnabled = false;
@ -443,6 +449,21 @@ class RealTimeCommunicationManager extends ChangeNotifier {
bool get shouldShowRoomVisualEffects => bool get shouldShowRoomVisualEffects =>
currenRoom != null && _roomVisualEffectsEnabled; currenRoom != null && _roomVisualEffectsEnabled;
void _startVoiceRoomForegroundService() {
final roomProfile = currenRoom?.roomProfile?.roomProfile;
final roomName = (roomProfile?.roomName ?? "").trim();
final roomAccount = (roomProfile?.roomAccount ?? "").trim();
unawaited(
SCVoiceRoomForegroundService.start(
roomName: roomName.isNotEmpty ? roomName : roomAccount,
),
);
}
void _stopVoiceRoomForegroundService() {
unawaited(SCVoiceRoomForegroundService.stop());
}
bool get _shouldDeferSeatInteractionForRoomStartup => bool get _shouldDeferSeatInteractionForRoomStartup =>
_roomStartupStatus == RoomStartupStatus.loading; _roomStartupStatus == RoomStartupStatus.loading;
@ -1943,6 +1964,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Future<void> releaseRtcEngineForAppTermination() async { Future<void> releaseRtcEngineForAppTermination() async {
final rtcEngine = engine; final rtcEngine = engine;
if (rtcEngine == null) { if (rtcEngine == null) {
await SCVoiceRoomForegroundService.stop();
return; return;
} }
@ -1968,6 +1990,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
} catch (e) { } catch (e) {
debugPrint('rtc释放引擎出错: $e'); debugPrint('rtc释放引擎出错: $e');
} }
await SCVoiceRoomForegroundService.stop();
} }
void initializeAudioVolumeIndicationCallback( void initializeAudioVolumeIndicationCallback(
@ -2114,6 +2137,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
_startVoiceRoomForegroundService();
VoiceRoomRoute.openVoiceRoom(context); VoiceRoomRoute.openVoiceRoom(context);
return; return;
} }
@ -2137,6 +2161,9 @@ class RealTimeCommunicationManager extends ChangeNotifier {
_startRoomStatePolling(); _startRoomStatePolling();
_setRoomStartupReady(); _setRoomStartupReady();
setRoomVisualEffectsEnabled(true); setRoomVisualEffectsEnabled(true);
_startRoomRedPacketPresenceHeartbeat(roomId);
_refreshRoomRedPacketListAfterEntry(roomId: roomId);
_startVoiceRoomForegroundService();
VoiceRoomRoute.openVoiceRoom(context); VoiceRoomRoute.openVoiceRoom(context);
} else { } else {
if (currenRoom != null) { if (currenRoom != null) {
@ -2157,6 +2184,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
previewData: previewData, previewData: previewData,
previewSeatCount: previewSeatCount, previewSeatCount: previewSeatCount,
); );
_startVoiceRoomForegroundService();
VoiceRoomRoute.openVoiceRoom(context); VoiceRoomRoute.openVoiceRoom(context);
notifyListeners(); notifyListeners();
final entryRequestSerial = ++_roomEntryRequestSerial; final entryRequestSerial = ++_roomEntryRequestSerial;
@ -2192,6 +2220,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
} }
currenRoom = _mergeVoiceRoomVipHints(enteredRoom, currenRoom); currenRoom = _mergeVoiceRoomVipHints(enteredRoom, currenRoom);
_syncCurrentUserVipHintFromRoom(currenRoom); _syncCurrentUserVipHintFromRoom(currenRoom);
_startVoiceRoomForegroundService();
unawaited(_refreshCurrentUserVipHintForRoom(entryRequestSerial)); unawaited(_refreshCurrentUserVipHintForRoom(entryRequestSerial));
_currentRoomIsEntryPreview = false; _currentRoomIsEntryPreview = false;
_previewRoomSeatCount = null; _previewRoomSeatCount = null;
@ -2207,6 +2236,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
return; return;
} }
debugPrint('[RoomStartup] entry room failed: $e\n$stackTrace'); debugPrint('[RoomStartup] entry room failed: $e\n$stackTrace');
_stopVoiceRoomForegroundService();
_setRoomStartupFailed(RoomStartupFailureType.entry); _setRoomStartupFailed(RoomStartupFailureType.entry);
} }
} }
@ -2674,6 +2704,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
debugPrint( debugPrint(
'[RoomStartup] join IM group failed code=${joinResult?.code} desc=${joinResult?.desc}', '[RoomStartup] join IM group failed code=${joinResult?.code} desc=${joinResult?.desc}',
); );
_stopVoiceRoomForegroundService();
_setRoomStartupFailed(RoomStartupFailureType.im); _setRoomStartupFailed(RoomStartupFailureType.im);
return; return;
} }
@ -2693,6 +2724,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
rtmProvider?.loadRoomHistoryMessages(groupId, count: 20) ?? rtmProvider?.loadRoomHistoryMessages(groupId, count: 20) ??
Future<void>.value(), Future<void>.value(),
); );
_refreshRoomRedPacketListAfterEntry(roomId: roomId);
await Future.wait([microphoneListFuture, onlineUsersFuture]); await Future.wait([microphoneListFuture, onlineUsersFuture]);
if (!_isActiveRoomStartup(entryRequestSerial, roomId)) { if (!_isActiveRoomStartup(entryRequestSerial, roomId)) {
@ -2748,6 +2780,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
return; return;
} }
debugPrint('[RoomStartup] bootstrap failed: $error\n$stackTrace'); debugPrint('[RoomStartup] bootstrap failed: $error\n$stackTrace');
_stopVoiceRoomForegroundService();
_setRoomStartupFailed(failureType); _setRoomStartupFailed(failureType);
} }
} }
@ -2988,11 +3021,17 @@ class RealTimeCommunicationManager extends ChangeNotifier {
Future<List<SCRoomRedPacketListRes>> loadRoomRedPacketList( Future<List<SCRoomRedPacketListRes>> loadRoomRedPacketList(
int current, int current,
) async { ) async {
final roomId = currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? "";
if (roomId.isEmpty) {
return const <SCRoomRedPacketListRes>[];
}
var result = await SCChatRoomRepository().roomRedPacketList( var result = await SCChatRoomRepository().roomRedPacketList(
currenRoom?.roomProfile?.roomProfile?.id ?? "", roomId,
current, current,
); );
if (current == 1) { final currentRoomId =
currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? "";
if (current == 1 && currentRoomId == roomId) {
redPacketList = result; redPacketList = result;
notifyListeners(); notifyListeners();
} }
@ -3055,19 +3094,18 @@ class RealTimeCommunicationManager extends ChangeNotifier {
); );
} }
void _refreshRoomRedPacketListAfterEntry() { void _refreshRoomRedPacketListAfterEntry({String? roomId}) {
final roomId = currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? ""; final normalizedRoomId =
if (roomId.isEmpty) { roomId?.trim() ??
currenRoom?.roomProfile?.roomProfile?.id?.trim() ??
"";
if (normalizedRoomId.isEmpty) {
return; return;
} }
for (final delay in const [ for (final delay in _roomRedPacketEntryRefreshDelays) {
Duration.zero,
Duration(seconds: 2),
Duration(seconds: 6),
]) {
unawaited( unawaited(
Future<void>.delayed(delay).then((_) { Future<void>.delayed(delay).then((_) {
return _refreshRoomRedPacketListIfCurrent(roomId); return _refreshRoomRedPacketListIfCurrent(normalizedRoomId);
}), }),
); );
} }
@ -3381,6 +3419,7 @@ class RealTimeCommunicationManager extends ChangeNotifier {
/// ///
void _clearData() { void _clearData() {
_roomEntryRequestSerial += 1; _roomEntryRequestSerial += 1;
_stopVoiceRoomForegroundService();
_roomEntryEffectTimer?.cancel(); _roomEntryEffectTimer?.cancel();
_roomEntryEffectTimer = null; _roomEntryEffectTimer = null;
_stopRoomRedPacketPresenceHeartbeat(); _stopRoomRedPacketPresenceHeartbeat();

View File

@ -571,6 +571,7 @@ class RealTimeMessagingManager extends ChangeNotifier {
logined = true; logined = true;
print('tim 登录成功'); print('tim 登录成功');
await initConversation(); await initConversation();
unawaited(syncRoomRedPacketBroadcastGroup());
} else { } else {
// //
//print('timm 需要重新登录2'); //print('timm 需要重新登录2');
@ -591,16 +592,26 @@ class RealTimeMessagingManager extends ChangeNotifier {
_onNewMessage(V2TimMessage message) { _onNewMessage(V2TimMessage message) {
if (message.groupID != null) { if (message.groupID != null) {
final groupId = message.groupID!;
/// / 广 /// / 广
if (message.groupID == SCGlobalConfig.bigBroadcastGroup || if (groupId == SCGlobalConfig.bigBroadcastGroup ||
message.groupID == roomRedPacketBroadcastGroupId) { groupId == roomRedPacketBroadcastGroupId) {
_newBroadCastMsgRecv(message.groupID!, message); _newBroadCastMsgRecv(groupId, message);
return;
}
if (_shouldRouteUntrackedRoomRedPacketBroadcast(groupId, message)) {
debugPrint(
'[RoomRedPacket][IM] route untracked group as region broadcast '
'group=$groupId cachedRegionGroup=$roomRedPacketBroadcastGroupId',
);
_newBroadCastMsgRecv(groupId, message, forceRegionBroadcast: true);
return; return;
} }
/// ///
for (var element in onNewMessageListenerGroupMap.values) { for (var element in onNewMessageListenerGroupMap.values) {
element?.call(message.groupID!, message); element?.call(groupId, message);
} }
} else { } else {
/// ///
@ -1780,6 +1791,38 @@ class RealTimeMessagingManager extends ChangeNotifier {
''; '';
} }
String _currentVoiceRoomGroupId() {
final currentContext = context;
if (currentContext == null || !currentContext.mounted) {
return '';
}
return Provider.of<RealTimeCommunicationManager>(
currentContext,
listen: false,
).currenRoom?.roomProfile?.roomProfile?.roomAccount?.trim() ??
'';
}
bool _shouldRouteUntrackedRoomRedPacketBroadcast(
String groupId,
V2TimMessage message,
) {
if (groupId == _currentVoiceRoomGroupId()) {
return false;
}
final customData = message.customElem?.data;
if (customData == null || customData.isEmpty) {
return false;
}
try {
final decoded = json.decode(customData);
final payload = _broadcastPayloadMap(decoded);
return payload['type'] == SCRoomMsgType.roomRedPacket;
} catch (_) {
return false;
}
}
Map<String, dynamic> _broadcastPayloadMap(dynamic data) { Map<String, dynamic> _broadcastPayloadMap(dynamic data) {
if (data is Map<String, dynamic>) { if (data is Map<String, dynamic>) {
return data; return data;
@ -1902,11 +1945,16 @@ class RealTimeMessagingManager extends ChangeNotifier {
} }
///广 ///广
_newBroadCastMsgRecv(String groupID, V2TimMessage message) async { _newBroadCastMsgRecv(
String groupID,
V2TimMessage message, {
bool forceRegionBroadcast = false,
}) async {
try { try {
String? customData = message.customElem?.data; String? customData = message.customElem?.data;
if (customData != null && customData.isNotEmpty) { if (customData != null && customData.isNotEmpty) {
final isRegionBroadcastGroup = groupID == roomRedPacketBroadcastGroupId; final isRegionBroadcastGroup =
forceRegionBroadcast || groupID == roomRedPacketBroadcastGroupId;
final data = json.decode(customData); final data = json.decode(customData);
var type = data["type"]; var type = data["type"];
if (type == "SYS_ACTIVITY") { if (type == "SYS_ACTIVITY") {
@ -1988,7 +2036,25 @@ class RealTimeMessagingManager extends ChangeNotifier {
} else if (type == SCRoomMsgType.roomRedPacket) { } else if (type == SCRoomMsgType.roomRedPacket) {
/// ///
var fData = data["data"]; var fData = data["data"];
final payload = _broadcastPayloadMap(fData);
final packetId = _payloadText(payload['packetId']);
final packetRoomId = _payloadText(payload["roomId"]);
debugPrint(
'[RoomRedPacket][IM] recv broadcast packetId=$packetId '
'roomId=$packetRoomId group=$groupID '
'regionGroup=$roomRedPacketBroadcastGroupId '
'isRegion=$isRegionBroadcastGroup '
'payloadRegion=${_payloadText(payload['regionCode'])} '
'currentRegion=${roomRedPacketBroadcastRegionCode ?? ''} '
'currentRoom=${_currentVoiceRoomId()}',
);
if (!_isSameRoomRedPacketRegion(fData)) { if (!_isSameRoomRedPacketRegion(fData)) {
debugPrint(
'[RoomRedPacket][IM] skip broadcast region mismatch '
'packetId=$packetId '
'payloadRegion=${_payloadText(payload['regionCode'])} '
'currentRegion=${roomRedPacketBroadcastRegionCode ?? ''}',
);
return; return;
} }
RoomRedPacketPendingCache.instance.upsertPayload(fData); RoomRedPacketPendingCache.instance.upsertPayload(fData);
@ -2007,7 +2073,6 @@ class RealTimeMessagingManager extends ChangeNotifier {
: '', : '',
priority: 1000, priority: 1000,
); );
final packetRoomId = _payloadText(fData["roomId"]);
if (_isCurrentVisibleVoiceRoom(packetRoomId)) { if (_isCurrentVisibleVoiceRoom(packetRoomId)) {
final rtcProvider = Provider.of<RealTimeCommunicationManager>( final rtcProvider = Provider.of<RealTimeCommunicationManager>(
context!, context!,
@ -2616,6 +2681,12 @@ class RealTimeMessagingManager extends ChangeNotifier {
return; return;
} }
debugPrint(
'[RoomRedPacket][IM] send broadcast packetId=${payload['packetId']} '
'roomId=${payload['roomId']} mode=${payload['packetMode']} '
'targets=${targetGroupIds.join(',')} regionGroup=$regionGroupId '
'region=${payload['regionCode'] ?? roomRedPacketBroadcastRegionCode ?? ''}',
);
final message = <String, dynamic>{ final message = <String, dynamic>{
'type': SCRoomMsgType.roomRedPacket, 'type': SCRoomMsgType.roomRedPacket,
'data': payload, 'data': payload,

View File

@ -0,0 +1,38 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
class SCVoiceRoomForegroundService {
static const MethodChannel _channel = MethodChannel(
'com.org.yumiparty/voice_room_foreground_service',
);
static Future<void> start({String? roomName}) async {
if (!Platform.isAndroid) {
return;
}
try {
await _channel.invokeMethod<void>('start', {
'roomName': roomName?.trim(),
});
} on PlatformException catch (e) {
debugPrint('[VoiceRoomForeground] start failed: ${e.message}');
} catch (e) {
debugPrint('[VoiceRoomForeground] start failed: $e');
}
}
static Future<void> stop() async {
if (!Platform.isAndroid) {
return;
}
try {
await _channel.invokeMethod<void>('stop');
} on PlatformException catch (e) {
debugPrint('[VoiceRoomForeground] stop failed: ${e.message}');
} catch (e) {
debugPrint('[VoiceRoomForeground] stop failed: $e');
}
}
}

View File

@ -24,12 +24,14 @@ typedef FloatingScreenManager = OverlayManager;
class OverlayManager { class OverlayManager {
static const int _maxLowPerformanceCompensationQueueLength = 8; static const int _maxLowPerformanceCompensationQueueLength = 8;
static const int _maxDeferredRegionRedPacketCount = 12;
static const Duration _compensationDedupWindow = Duration(seconds: 3); static const Duration _compensationDedupWindow = Duration(seconds: 3);
static const Duration _redPacketFloatingDedupWindow = Duration(minutes: 30); static const Duration _redPacketFloatingDedupWindow = Duration(minutes: 30);
final SCPriorityQueue<SCFloatingMessage> _messageQueue = SCPriorityQueue( final SCPriorityQueue<SCFloatingMessage> _messageQueue = SCPriorityQueue(
(a, b) => b.priority.compareTo(a.priority), (a, b) => b.priority.compareTo(a.priority),
); );
final List<SCFloatingMessage> _deferredRegionRedPacketMessages = [];
final Map<String, DateTime> _recentCompensationKeys = {}; final Map<String, DateTime> _recentCompensationKeys = {};
final Map<String, DateTime> _recentFloatingKeys = {}; final Map<String, DateTime> _recentFloatingKeys = {};
bool _isPlaying = false; bool _isPlaying = false;
@ -53,6 +55,12 @@ class OverlayManager {
if (_isRecentFloatingDuplicate(message)) { if (_isRecentFloatingDuplicate(message)) {
return; return;
} }
if (message.type == 4) {
debugPrint(
'[RoomRedPacket][Floating] enqueue packetId=${message.toUserId} '
'roomId=${message.roomId} region=${message.isRegionBroadcast}',
);
}
_rememberFloatingMessage(message); _rememberFloatingMessage(message);
_enqueueMessage( _enqueueMessage(
message, message,
@ -61,6 +69,7 @@ class OverlayManager {
} else { } else {
_removeActiveMessage(scheduleNext: false); _removeActiveMessage(scheduleNext: false);
_messageQueue.clear(); _messageQueue.clear();
_deferredRegionRedPacketMessages.clear();
_isPlaying = false; _isPlaying = false;
_isProcessing = false; _isProcessing = false;
_isDisposed = false; _isDisposed = false;
@ -256,9 +265,20 @@ class OverlayManager {
// //
if (_messageQueue.isEmpty) return; if (_messageQueue.isEmpty) return;
final messageToProcess = _messageQueue.first; final messageToProcess = _messageQueue.first;
if (messageToProcess == null) {
_messageQueue.removeFirst();
_safeScheduleNext();
return;
}
if (!_shouldDisplayMessage(context, messageToProcess)) { if (!_shouldDisplayMessage(context, messageToProcess)) {
_messageQueue.removeFirst(); _messageQueue.removeFirst();
if (_shouldDeferRegionRedPacketMessage(context, messageToProcess)) {
_deferRegionRedPacketMessage(context, messageToProcess);
_safeScheduleNext();
return;
}
_logRegionRedPacketDrop(context, messageToProcess);
_safeScheduleNext(); _safeScheduleNext();
return; return;
} }
@ -401,6 +421,7 @@ class OverlayManager {
_removeRegionBroadcastMessages(); _removeRegionBroadcastMessages();
return; return;
} }
_restoreDeferredRegionRedPacketMessages();
_safeScheduleNext(); _safeScheduleNext();
} }
@ -434,8 +455,10 @@ class OverlayManager {
if (SCGlobalConfig.isFloatingBroadcastClosed) { if (SCGlobalConfig.isFloatingBroadcastClosed) {
_removeActiveMessage(scheduleNext: false); _removeActiveMessage(scheduleNext: false);
_messageQueue.clear(); _messageQueue.clear();
_deferredRegionRedPacketMessages.clear();
return; return;
} }
_restoreDeferredRegionRedPacketMessages();
final context = navigatorKey.currentState?.context; final context = navigatorKey.currentState?.context;
final activeMessage = _currentMessage; final activeMessage = _currentMessage;
if (context != null && if (context != null &&
@ -456,6 +479,7 @@ class OverlayManager {
_currentOverlayEntry = null; _currentOverlayEntry = null;
_currentMessage = null; _currentMessage = null;
_messageQueue.clear(); _messageQueue.clear();
_deferredRegionRedPacketMessages.clear();
_recentCompensationKeys.clear(); _recentCompensationKeys.clear();
_isPlaying = false; _isPlaying = false;
_isProcessing = false; _isProcessing = false;
@ -497,6 +521,86 @@ class OverlayManager {
bool get _isSuppressed => _suppressedCount > 0; bool get _isSuppressed => _suppressedCount > 0;
bool _isRegionRedPacketMessage(SCFloatingMessage message) {
return message.type == 4 && message.isRegionBroadcast;
}
bool _shouldDeferRegionRedPacketMessage(
BuildContext context,
SCFloatingMessage message,
) {
if (!_isRegionRedPacketMessage(message) ||
SCGlobalConfig.isFloatingBroadcastClosed) {
return false;
}
return !_shouldDisplayRegionBroadcastMessage(context, message);
}
void _deferRegionRedPacketMessage(
BuildContext context,
SCFloatingMessage message,
) {
final key = _floatingDedupKeyFor(message);
if (key != null &&
_deferredRegionRedPacketMessages.any(
(item) => _floatingDedupKeyFor(item) == key,
)) {
return;
}
while (_deferredRegionRedPacketMessages.length >=
_maxDeferredRegionRedPacketCount) {
_deferredRegionRedPacketMessages.removeAt(0);
}
_deferredRegionRedPacketMessages.add(message);
debugPrint(
'[RoomRedPacket][Floating] defer packetId=${message.toUserId} '
'roomId=${message.roomId} ${_displayStateForLog(context)}',
);
}
void _restoreDeferredRegionRedPacketMessages() {
if (_deferredRegionRedPacketMessages.isEmpty) {
return;
}
final messages = List<SCFloatingMessage>.from(
_deferredRegionRedPacketMessages,
);
_deferredRegionRedPacketMessages.clear();
for (final message in messages) {
_messageQueue.add(message);
}
debugPrint(
'[RoomRedPacket][Floating] restore deferred count=${messages.length}',
);
}
void _logRegionRedPacketDrop(
BuildContext context,
SCFloatingMessage message,
) {
if (message.type != 4) {
return;
}
debugPrint(
'[RoomRedPacket][Floating] drop packetId=${message.toUserId} '
'roomId=${message.roomId} region=${message.isRegionBroadcast} '
'${_displayStateForLog(context)}',
);
}
String _displayStateForLog(BuildContext context) {
final rtcProvider = Provider.of<RealTimeCommunicationManager>(
context,
listen: false,
);
final currentRoomId =
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? "";
return 'homeVisible=$_homeRootTabsVisible '
'roomVisible=${rtcProvider.shouldShowRoomVisualEffects} '
'currentRoomId=$currentRoomId '
'scope=${SCGlobalConfig.floatingBroadcastScope}';
}
bool _shouldDisplayMessage(BuildContext context, SCFloatingMessage? message) { bool _shouldDisplayMessage(BuildContext context, SCFloatingMessage? message) {
if (message == null) { if (message == null) {
return false; return false;
@ -582,7 +686,7 @@ class OverlayManager {
while (_messageQueue.isNotEmpty) { while (_messageQueue.isNotEmpty) {
final message = _messageQueue.removeFirst(); final message = _messageQueue.removeFirst();
if (!message.isRegionBroadcast) { if (!message.isRegionBroadcast || _isRegionRedPacketMessage(message)) {
newQueue.add(message); newQueue.add(message);
} }
} }

View File

@ -607,6 +607,11 @@ class _RoomRedPacketSendPanelState extends State<RoomRedPacketSendPanel> {
(_selectedPickupMinutes > 0 ? _selectedPickupMinutes * 60 : 0) (_selectedPickupMinutes > 0 ? _selectedPickupMinutes * 60 : 0)
..['claimStartTime'] = claimStartTime ..['claimStartTime'] = claimStartTime
..['grabbed'] = result.grabbed ?? false; ..['grabbed'] = result.grabbed ?? false;
final roomRegionCode = rtcProvider.currenRoom?.roomProfile?.regionCode;
if ((payload['regionCode']?.toString().trim() ?? '').isEmpty &&
(roomRegionCode?.trim().isNotEmpty ?? false)) {
payload['regionCode'] = roomRegionCode!.trim();
}
final rtmProvider = Provider.of<RtmProvider>(context, listen: false); final rtmProvider = Provider.of<RtmProvider>(context, listen: false);
rtmProvider.addMsg( rtmProvider.addMsg(

View File

@ -68,6 +68,7 @@ class _RoomRedPacketFloatingEntryState
Timer? _entryRefreshTimer; Timer? _entryRefreshTimer;
bool _refreshingList = false; bool _refreshingList = false;
int _elapsedSeconds = 0; int _elapsedSeconds = 0;
String _observedRoomId = '';
String _verifiedRoomId = ''; String _verifiedRoomId = '';
int _verifiedRoomRefreshCount = 0; int _verifiedRoomRefreshCount = 0;
Set<String> _verifiedKnownPacketIds = const <String>{}; Set<String> _verifiedKnownPacketIds = const <String>{};
@ -80,6 +81,11 @@ class _RoomRedPacketFloatingEntryState
const Duration(seconds: 1), const Duration(seconds: 1),
(_) => _onEntryTimerTick(), (_) => _onEntryTimerTick(),
); );
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_refreshRoomRedPacketList();
}
});
} }
@override @override
@ -93,6 +99,7 @@ class _RoomRedPacketFloatingEntryState
if (!mounted) { if (!mounted) {
return; return;
} }
_refreshRoomRedPacketListIfRoomChanged();
setState(() {}); setState(() {});
_elapsedSeconds++; _elapsedSeconds++;
if (_elapsedSeconds % _listRefreshIntervalSeconds == 0) { if (_elapsedSeconds % _listRefreshIntervalSeconds == 0) {
@ -100,6 +107,24 @@ class _RoomRedPacketFloatingEntryState
} }
} }
void _refreshRoomRedPacketListIfRoomChanged() {
try {
final rtcProvider = Provider.of<RtcProvider>(context, listen: false);
final currentRoomId =
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id?.trim() ?? '';
if (currentRoomId.isEmpty || currentRoomId == _observedRoomId) {
return;
}
_observedRoomId = currentRoomId;
_elapsedSeconds = 0;
_verifiedRoomId = '';
_verifiedRoomRefreshCount = 0;
_verifiedKnownPacketIds = const <String>{};
_verifiedVisiblePacketIds = const <String>{};
_refreshRoomRedPacketList();
} catch (_) {}
}
void _refreshRoomRedPacketList() { void _refreshRoomRedPacketList() {
if (_refreshingList || !mounted) { if (_refreshingList || !mounted) {
return; return;
@ -116,6 +141,13 @@ class _RoomRedPacketFloatingEntryState
rtcProvider rtcProvider
.loadRoomRedPacketList(1) .loadRoomRedPacketList(1)
.then((result) { .then((result) {
final activeRoomId =
rtcProvider.currenRoom?.roomProfile?.roomProfile?.id
?.trim() ??
'';
if (activeRoomId != currentRoomId) {
return result;
}
final serverPackets = final serverPackets =
result.map(RoomRedPacketUiData.fromListRes).toList(); result.map(RoomRedPacketUiData.fromListRes).toList();
final serverPacketIds = final serverPacketIds =

View File

@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.3.0+10 version: 1.3.0+11
environment: environment:

View File

@ -562,6 +562,7 @@
- 已修正红包领取成功公屏消息的多语言问题:领取后不再发送已翻译好的普通文本,改为 `ROOM_RED_PACKET_CLAIM` 结构化房间 IM只传领取金额、红包发送人名和 `packetId`,公屏渲染时按当前客户端语言生成文案,避免英文用户看到阿语消息。 - 已修正红包领取成功公屏消息的多语言问题:领取后不再发送已翻译好的普通文本,改为 `ROOM_RED_PACKET_CLAIM` 结构化房间 IM只传领取金额、红包发送人名和 `packetId`,公屏渲染时按当前客户端语言生成文案,避免英文用户看到阿语消息。
- 已补强房外延时红包飘屏和领取通知去重:首页四个一级 Tab 可见/回到前台时会重新同步当前语区红包 IM 群;语区红包飘屏在首页 Tab 中不再受“只看房间飘屏”设置影响;领取成功公屏通知按 `packetId + 领取人ID` 生成稳定去重键,避免本地插入和 IM 回包/历史拉取造成两条一模一样的领取消息。 - 已补强房外延时红包飘屏和领取通知去重:首页四个一级 Tab 可见/回到前台时会重新同步当前语区红包 IM 群;语区红包飘屏在首页 Tab 中不再受“只看房间飘屏”设置影响;领取成功公屏通知按 `packetId + 领取人ID` 生成稳定去重键,避免本地插入和 IM 回包/历史拉取造成两条一模一样的领取消息。
- 已定位并修复公屏上滑历史反复弹 `user info not found`:历史消息 item 重建时会重复调用用户资料接口,缺失用户返回错误后被网络层 toast现在房间公屏补拉用户资料改为静默请求并对同一 userId 的请求/失败结果做缓存5 分钟内不再反复请求同一个不存在用户。 - 已定位并修复公屏上滑历史反复弹 `user info not found`:历史消息 item 重建时会重复调用用户资料接口,缺失用户返回错误后被网络层 toast现在房间公屏补拉用户资料改为静默请求并对同一 userId 的请求/失败结果做缓存5 分钟内不再反复请求同一个不存在用户。
- 已继续排查房外/跨房红包飘屏缺失IM 重登成功后会立即重新同步当前语区红包群;收到未登记但内容为 `ROOM_RED_PACKET` 的群消息时会按语区红包飘屏兜底处理,避免语区群 id 尚未写入时被普通群消息分支吞掉;区域红包在首页 Tab 暂不可见时不再直接从飘屏队列丢弃,而是挂起后在回到首页 Tab 时恢复播放;发送红包 payload 也会补齐当前房间 `regionCode`,便于日志核对和前端兜底过滤。
## 已知问题 ## 已知问题
- 已接入语音房表情包素材第一版:桌面 `表情素材` 中的 `fluent_emoji``monkey` gif 已纳入项目资源,底部聊天入口改为图稿里的短白色胶囊并移除内部输入图标,右侧新增独立表情按钮;同一个房间输入弹层现在可从表情按钮直接打开表情包面板,原 unicode emoji 面板已替换为本地 gif 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。 - 已接入语音房表情包素材第一版:桌面 `表情素材` 中的 `fluent_emoji``monkey` gif 已纳入项目资源,底部聊天入口改为图稿里的短白色胶囊并移除内部输入图标,右侧新增独立表情按钮;同一个房间输入弹层现在可从表情按钮直接打开表情包面板,原 unicode emoji 面板已替换为本地 gif 表情。发送逻辑同步按最新规则分流:用户在麦上发表情时只在自己麦位播放,不进入聊天列表;用户未上麦发表情时进入房间聊天框展示,不触发麦位动画。