Merge pull request 'feat(video_calls): ios calle mic fix, infinite loading after accepting call on an oppened app fix, incaming call declining after declining on a different device' (#1095) from misha/eatvio_app:issue-1089 into main

Reviewed-on: eatvio/eatvio_app#1095
This commit is contained in:
2026-10-22 11:57:57 +00:00
24 changed files with 924 additions and 142 deletions

View File

@@ -13,14 +13,12 @@ object CallKitAcceptIntentMapper {
return null
}
return hashMapOf(
"action" to action,
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
"handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""),
"nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""),
"avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""),
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
)
return payloadMap(data, action)
}
fun declinePayloadFromBundle(data: Bundle, action: String): Map<String, Any?>? {
if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null
return payloadMap(data, action)
}
fun payloadFromIntent(intent: Intent?): Map<String, Any?>? {
@@ -35,6 +33,16 @@ object CallKitAcceptIntentMapper {
return null
}
return payloadMap(data, action)
}
fun clearCallKitLaunchIntent(intent: Intent?) {
if (intent == null) return
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
intent.action = null
}
private fun payloadMap(data: Bundle, action: String): Map<String, Any?> {
return hashMapOf(
"action" to action,
"id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""),
@@ -44,10 +52,4 @@ object CallKitAcceptIntentMapper {
"extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA),
)
}
fun clearCallKitLaunchIntent(intent: Intent?) {
if (intent == null) return
intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA)
intent.action = null
}
}

View File

@@ -16,6 +16,12 @@ object CallKitIntentBridge {
CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload ->
PendingCallKitAcceptBridge.store(payload)
}
CallKitAcceptIntentMapper.declinePayloadFromBundle(data, action)?.let { payload ->
PendingCallKitDeclineBridge.store(payload)
if (MainActivity.isInForeground) {
MainActivity.onPendingDecline?.invoke()
}
}
}
val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data)

View File

@@ -25,6 +25,9 @@ class MainActivity: FlutterActivity() {
@JvmStatic
var isInForeground: Boolean = false
private set
@JvmStatic
var onPendingDecline: (() -> Unit)? = null
}
private val channelName = "com.eatvioapp/hms_push"
@@ -38,13 +41,18 @@ class MainActivity: FlutterActivity() {
override fun onResume() {
super.onResume()
isInForeground = true
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
captureCallKitLaunchIntent(intent)
if (PendingCallKitAcceptBridge.peek() != null) {
scheduleDeliverPendingCallKitAccept()
}
if (PendingCallKitDeclineBridge.peek() != null) {
scheduleDeliverPendingCallKitDecline()
}
}
override fun onPause() {
onPendingDecline = null
isInForeground = false
super.onPause()
}
@@ -55,6 +63,7 @@ class MainActivity: FlutterActivity() {
methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName)
videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName)
onPendingDecline = { scheduleDeliverPendingCallKitDecline() }
videoCallPushChannel?.setMethodCallHandler { call, result ->
when (call.method) {
"dismissIncomingCalls" -> {
@@ -95,6 +104,9 @@ class MainActivity: FlutterActivity() {
"peekPendingCallKitAccept" -> {
result.success(PendingCallKitAcceptBridge.peekPending())
}
"drainPendingCallKitDecline" -> {
result.success(PendingCallKitDeclineBridge.drain())
}
else -> result.notImplemented()
}
}
@@ -250,6 +262,30 @@ class MainActivity: FlutterActivity() {
channel.invokeMethod("onCallKitLaunchIntent", payload)
}
private fun scheduleDeliverPendingCallKitDecline() {
PendingCallKitDeclineBridge.scheduleDeliverToFlutter(
deliver = { deliverPendingCallKitDeclineToFlutter() },
)
}
private fun deliverPendingCallKitDeclineToFlutter() {
val payload = PendingCallKitDeclineBridge.peek() ?: return
val channel = videoCallPushChannel
if (channel == null) {
android.util.Log.w(
"VideoCallPush",
"deliverPendingCallKitDeclineToFlutter: channel not ready",
)
return
}
android.util.Log.i(
"VideoCallPush",
"deliverPendingCallKitDeclineToFlutter: id=${payload["id"]}",
)
channel.invokeMethod("onCallKitDecline", payload)
}
private fun handleIntentForActionUrl(intent: Intent?) {
if (intent == null) return

View File

@@ -0,0 +1,44 @@
package com.eatvioapp.app
import android.os.Handler
import android.os.Looper
import android.util.Log
/// Stores a decline until Flutter can POST /video-call/decline.
///
/// The plugin broadcasts the decline. If the Flutter engine is not attached,
/// that broadcast never reaches the backend and the call keeps ringing on the
/// user's other devices.
object PendingCallKitDeclineBridge {
private const val TAG = "PendingCallKitDecline"
@Volatile
private var pendingPayload: Map<String, Any?>? = null
private val mainHandler = Handler(Looper.getMainLooper())
fun store(payload: Map<String, Any?>) {
pendingPayload = payload
Log.i(TAG, "stored pending decline id=${payload["id"]}")
}
fun peek(): Map<String, Any?>? = pendingPayload
fun drain(): Map<String, Any?>? {
val payload = pendingPayload
pendingPayload = null
if (payload != null) {
Log.i(TAG, "drained pending decline id=${payload["id"]}")
}
return payload
}
fun scheduleDeliverToFlutter(
deliver: () -> Unit,
delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L),
) {
for (delayMs in delaysMs) {
mainHandler.postDelayed({ deliver() }, delayMs)
}
}
}

View File

@@ -558,7 +558,10 @@ object VideoCallPushBridge {
if (extraCallId != null && extraCallId.lowercase() == normalizedCallId) {
return true
}
return false
// A ring that already stores its own call id must not be closed by
// another call in the same chat. Legacy rings have no call id and
// still match the conversation.
if (!extraCallId.isNullOrBlank()) return false
}
val normalizedConversation = conversationUuid?.trim()?.lowercase() ?: return false

View File

@@ -19,6 +19,7 @@ import flutter_foreground_task
private let pendingAcceptedCallTtl: TimeInterval = 120
private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics"
private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session"
private var callAudioChannel: FlutterMethodChannel?
private let appUrlScheme = "eatvio"
/// True only if CallKit ended while the in-app call must keep audio.
/// Incoming accept keeps CallKit up, so this stays false after answer.
@@ -115,6 +116,7 @@ import flutter_foreground_task
name: callAudioSessionChannelName,
binaryMessenger: flutterController.binaryMessenger
)
self.callAudioChannel = callAudioChannel
callAudioChannel.setMethodCallHandler { [weak self] call, result in
guard let self else {
result(nil)
@@ -1127,6 +1129,14 @@ import flutter_foreground_task
// misses the session CallKit just activated; toggling after that
// does not restart the ADM, so the incoming microphone stays silent.
rtcSession.isAudioEnabled = true
// setCallConnected requests a second CXAnswerCallAction. That fires
// didActivate again after the microphone track already exists and can
// stop AudioEngine. Dart starts recording on each activation once the
// track is published. The first activation (accept, no track yet) is a
// no-op on the Dart side.
DispatchQueue.main.async { [weak self] in
self?.callAudioChannel?.invokeMethod("audioSessionActivated", arguments: nil)
}
}
func didDeactivateAudioSession(_ audioSession: AVAudioSession) {

View File

@@ -1,16 +1,70 @@
import 'dart:io';
import 'dart:math';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Stable id of this install, sent as `hw_id` on push registration and on
/// video-call accept/decline.
///
/// Android `Build.ID` is the firmware id. Two phones on the same build share
/// it, so the backend treats them as one device and a decline on one does not
/// reach the other. A per-install id stays unique.
abstract final class DeviceHwIdProvider {
static Future<String?> current() async {
final deviceInfo = DeviceInfoPlugin();
static const androidInstallIdKey = 'eatvio.android_install_hw_id';
static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated';
static Future<String?> current({SharedPreferences? preferences}) async {
if (Platform.isIOS) {
return (await deviceInfo.iosInfo).identifierForVendor;
return (await DeviceInfoPlugin().iosInfo).identifierForVendor;
}
if (Platform.isAndroid) {
return (await deviceInfo.androidInfo).id;
return androidInstallId(preferences: preferences);
}
return null;
}
static Future<String> androidInstallId({
SharedPreferences? preferences,
}) async {
final prefs = preferences ?? await SharedPreferences.getInstance();
final existing = prefs.getString(androidInstallIdKey)?.trim();
if (existing != null && existing.length >= 8) return existing;
final created = _newInstallId();
await prefs.setString(androidInstallIdKey, created);
return created;
}
/// The previous Android registration used `Build.ID`. Drop that token once
/// and register again with the install id.
static Future<bool> androidTokenNeedsReregister({
SharedPreferences? preferences,
}) async {
if (!Platform.isAndroid) return false;
final prefs = preferences ?? await SharedPreferences.getInstance();
return prefs.getBool(androidTokenMigratedKey) != true;
}
static Future<void> markAndroidTokenReregistered({
SharedPreferences? preferences,
}) async {
final prefs = preferences ?? await SharedPreferences.getInstance();
await prefs.setBool(androidTokenMigratedKey, true);
}
static String _newInstallId() {
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
final hex = bytes
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
return '${hex.substring(0, 8)}-'
'${hex.substring(8, 12)}-'
'${hex.substring(12, 16)}-'
'${hex.substring(16, 20)}-'
'${hex.substring(20)}';
}
}

View File

@@ -3,6 +3,7 @@ import 'dart:developer';
import 'dart:io';
import 'package:eatvio_app/core/app_config.dart';
import 'package:eatvio_app/core/app_initializer.dart';
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
import 'package:eatvio_app/core/notifications/notification_permission_requester.dart';
import 'package:eatvio_app/core/push/hms_client_error.dart';
import 'package:device_info_plus/device_info_plus.dart';
@@ -44,15 +45,16 @@ class FirebaseApi {
Future<void> registerAnonymousPushToken() async {
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token == null || token.isEmpty) return;
final hwId = await DeviceHwIdProvider.current();
if (hwId == null || hwId.length < 8) return;
await sl<Dio>().post(
'/api/anonymous_token',
data: {
'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'hw_id': hwId,
'token': token,
},
).timeout(
@@ -72,23 +74,38 @@ class FirebaseApi {
if (!await TokenStorage.haveTokenInStorage()) return;
try {
await _requestNotificationPermission();
final deviceInfo = DeviceInfoPlugin();
final credentials = await _resolvePushCredentials();
final token = credentials.token;
if (token != null && token.isNotEmpty) {
await sl<Dio>().post(
'/api/profile/notification/token/register',
data: {
'platform': credentials.platform,
'hw_id': Platform.isIOS ? (await deviceInfo.iosInfo).identifierForVendor : (await deviceInfo.androidInfo).id,
'token': token,
},
).timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken timeout');
},
);
final hwId = await DeviceHwIdProvider.current();
final reregisterAndroid =
await DeviceHwIdProvider.androidTokenNeedsReregister();
if (token != null &&
token.isNotEmpty &&
hwId != null &&
hwId.length >= 8) {
if (reregisterAndroid) {
try {
await unregisterPushToken();
} catch (_) {}
}
await sl<Dio>()
.post(
'/api/profile/notification/token/register',
data: {
'platform': credentials.platform,
'hw_id': hwId,
'token': token,
},
)
.timeout(
const Duration(seconds: 10),
onTimeout: () {
throw TimeoutException('registerPushToken timeout');
},
);
if (reregisterAndroid) {
await DeviceHwIdProvider.markAndroidTokenReregistered();
}
}
} catch (e, st) {
log(e.toString(), stackTrace: st);
@@ -145,12 +162,13 @@ class FirebaseApi {
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
if (token == null || token.isEmpty) return;
final deviceInfo = DeviceInfoPlugin();
final hwId = await DeviceHwIdProvider.current();
if (hwId == null || hwId.length < 8) return;
await sl<Dio>().post(
'/api/profile/notification/token/voip/register',
data: {
'platform': 'ios',
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
'hw_id': hwId,
'token': token,
},
).timeout(

View File

@@ -50,6 +50,14 @@ class WsManager {
Stream<ChannelReadEvent>? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated');
/// The callee rejected the ring. Broadcast to the caller and the callee.
Stream<ChannelReadEvent>? get videoCallDeclinedStream =>
_privateMessagesChannel?.bind('video-call-declined');
/// The call was cancelled or hung up. Same channel the web client uses.
Stream<ChannelReadEvent>? get videoCallEndedStream =>
_privateMessagesChannel?.bind('video-call-ended');
Stream<ChannelReadEvent>? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated');
Stream<ChannelReadEvent>? get directChatStream => _directChatStream;

View File

@@ -11,6 +11,7 @@ import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_chat_signal.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -23,11 +24,15 @@ class ChatsCubit extends Cubit<ChatsState> {
StreamSubscription? newIncomingMessagesSubscription;
StreamSubscription? newLastMessageSubscription;
StreamSubscription<void>? reconnectedSubscription;
StreamSubscription? videoCallDeclinedSubscription;
StreamSubscription? videoCallEndedSubscription;
final Set<int> _presentedVideoCallMessageIds = {};
/// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
Future<void> Function({String? conversationUuid, String? callId})?
endActiveVideoCall;
ChatsCubit({
required this.chatRepository,
@@ -55,6 +60,26 @@ class ChatsCubit extends Cubit<ChatsState> {
_handleNewMessageViaSocket(event.data);
});
await videoCallDeclinedSubscription?.cancel();
videoCallDeclinedSubscription = wsManager.videoCallDeclinedStream?.listen(
(event) {
unawaited(
_closeCallFromSocket(
VideoCallSocketSignal.tryParseDeclined(event.data),
),
);
},
);
await videoCallEndedSubscription?.cancel();
videoCallEndedSubscription = wsManager.videoCallEndedStream?.listen((
event,
) {
unawaited(
_closeCallFromSocket(VideoCallSocketSignal.tryParseEnded(event.data)),
);
});
await reconnectedSubscription?.cancel();
reconnectedSubscription = wsManager.reconnected.listen((_) {
unawaited(getChatRooms());
@@ -262,6 +287,34 @@ class ChatsCubit extends Cubit<ChatsState> {
}
}
Future<void> _closeCallFromSocket(VideoCallSocketSignal? signal) async {
if (signal == null) return;
if (signal.endsActiveCall) {
final endActive = endActiveVideoCall;
if (endActive != null) {
await endActive(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
);
} else {
await VideoCallEndedPushHandler.endActiveCallIfMatches(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
);
}
}
final dismiss = dismissIncomingVideoCall;
if (dismiss != null) {
await dismiss(signal.conversationUuid);
return;
}
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: signal.conversationUuid,
callId: signal.callId,
reason: signal.reason,
);
}
Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
try {
final dismiss = dismissIncomingVideoCall;
@@ -308,6 +361,8 @@ class ChatsCubit extends Cubit<ChatsState> {
@override
Future<void> close() {
newIncomingMessagesSubscription?.cancel();
videoCallDeclinedSubscription?.cancel();
videoCallEndedSubscription?.cancel();
reconnectedSubscription?.cancel();
return super.close();
}

View File

@@ -42,6 +42,7 @@ class VideoCallManager {
_audioSession =
audioSessionCoordinator ?? VideoCallAudioSessionCoordinator(),
_resolveHwId = resolveHwId ?? DeviceHwIdProvider.current {
_audioSession.onIosAudioSessionActivated = _restartIosCaptureIfMicOn;
FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData);
if (Platform.isIOS) {
// LiveKit rewrites the audio session whenever a remote audio track
@@ -250,7 +251,23 @@ class VideoCallManager {
}
}
await endCall();
final previousUuid = _snapshot.conversationUuid;
final hasLiveRoom = _room != null && !_liveKitDisposed;
if (shouldPreserveCallKitWhenStartingCall(
previousConversationUuid: previousUuid,
nextConversationUuid: conversationUuid,
)) {
// The accepted CallKit call owns the iOS microphone. A full endCall
// here deactivates that session before the mic track exists.
if (hasLiveRoom) {
_disposeRoomEphemerals();
await _disconnectLiveKitWithTimeout();
_liveKitDisposed = false;
_callStageEntered = false;
}
} else {
await endCall();
}
_sessionMayRequestToken = true;
_resetSessionFlagsForNewRoom();
@@ -445,7 +462,6 @@ class VideoCallManager {
final shouldNotifyBackend = shouldNotifyCallEnded(
alreadyNotified: _callEndedNotified,
hasConversation: uuid.isNotEmpty,
peerStillInRoom: _roomHasRemoteUser(),
);
if (uuid.isNotEmpty) {
_callEndedNotified = true;
@@ -453,10 +469,16 @@ class VideoCallManager {
}
_sessionMayRequestToken = false;
_disposeRoomEphemerals();
await _disconnectLiveKitOnly();
await _disconnectLiveKitWithTimeout();
if (shouldNotifyBackend) {
try {
await _videoCallRepository.notifyCallEnded(uuid, callId: _activeCallId);
await _videoCallRepository.notifyCallEnded(
uuid,
callId: backendVideoCallId(
callId: _activeCallId,
conversationUuid: uuid,
),
);
} catch (_) {}
}
await _foreground.stopOngoingCall();
@@ -497,18 +519,19 @@ class VideoCallManager {
}
}
/// Decline from incoming UI before joining the LiveKit room.
/// Decline from the incoming UI before this device joins the room.
///
/// Uses `POST /decline` so the backend can stop ringing on the user's
/// other devices. Do not call `/ended` for a ringing decline.
/// `POST /video-call/decline` stops ringing on the callee's other devices
/// and tells the caller the call was declined.
Future<void> declineIncomingCall(
String conversationUuid, {
String? callId,
}) async {
final uuid = conversationUuid.trim();
final resolvedCallId = callId?.trim().isNotEmpty == true
? callId!.trim()
: _activeCallId;
final resolvedCallId = backendVideoCallId(
callId: callId?.trim().isNotEmpty == true ? callId!.trim() : _activeCallId,
conversationUuid: uuid,
);
if (uuid.isEmpty) {
await _cleanupLocalCallAfterDecline();
return;
@@ -549,7 +572,7 @@ class VideoCallManager {
_callEndedNotified = true;
_sessionMayRequestToken = false;
_disposeRoomEphemerals();
await _disconnectLiveKitOnly();
await _disconnectLiveKitWithTimeout();
await _foreground.stopOngoingCall();
await _stopCallAudioSession();
await _dismissIosCallKitAfterHangup(uuid);
@@ -591,8 +614,8 @@ class VideoCallManager {
audioCaptureOptions: _micCaptureOptions,
);
if (next) {
await _audioSession.restartCapture();
await _bindIosCallKitAudioEngine();
await _audioSession.restartCapture();
}
} catch (_) {
return;
@@ -791,8 +814,8 @@ class VideoCallManager {
true,
audioCaptureOptions: _micCaptureOptions,
);
await _audioSession.restartCapture();
await _bindIosCallKitAudioEngine();
await _audioSession.restartCapture();
} catch (_) {}
}
await _resumeVideoAfterForeground();
@@ -964,16 +987,17 @@ class VideoCallManager {
}
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost));
})
..on<lk.ParticipantDisconnectedEvent>((_) {
..on<lk.ParticipantDisconnectedEvent>((event) {
final room = _room;
if (room == null) return;
final departedIdentity = event.participant.identity;
if (!shouldForceEndCallAfterPeerLeft(
callStageEntered: _callStageEntered,
sessionConnected:
_snapshot.connectPhase == VideoCallConnectPhase.connected,
remoteIdentities: room.remoteParticipants.values.map(
(participant) => participant.identity,
),
remoteIdentities: room.remoteParticipants.values
.map((participant) => participant.identity)
.where((identity) => identity != departedIdentity),
)) {
return;
}
@@ -1074,10 +1098,15 @@ class VideoCallManager {
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
_emitSnapshot(_snapshot.copyWith(connectError: null));
try {
final tokenResponse = await _videoCallRepository.getParticipantToken(
_snapshot.conversationUuid,
callId: _activeCallId,
);
final tokenResponse = await _videoCallRepository
.getParticipantToken(
_snapshot.conversationUuid,
callId: backendVideoCallId(
callId: _activeCallId,
conversationUuid: _snapshot.conversationUuid,
),
)
.timeout(const Duration(seconds: 12));
if (_liveKitDisposed) return;
rememberCallId(tokenResponse.callId);
@@ -1123,10 +1152,15 @@ class VideoCallManager {
var token = _preparedToken;
if (wsUrl == null || token == null) {
if (!_tokenRequestAllowed(_snapshot.conversationUuid)) return;
final tokenResponse = await _videoCallRepository.getParticipantToken(
_snapshot.conversationUuid,
callId: _activeCallId,
);
final tokenResponse = await _videoCallRepository
.getParticipantToken(
_snapshot.conversationUuid,
callId: backendVideoCallId(
callId: _activeCallId,
conversationUuid: _snapshot.conversationUuid,
),
)
.timeout(const Duration(seconds: 12));
if (_liveKitDisposed) return;
rememberCallId(tokenResponse.callId);
wsUrl = tokenResponse.wsUrl;
@@ -1135,7 +1169,7 @@ class VideoCallManager {
_preparedToken = token;
}
await room.connect(wsUrl, token);
await room.connect(wsUrl, token).timeout(const Duration(seconds: 15));
if (_liveKitDisposed) return;
await _ensureLocalParticipantNamePublished();
@@ -1146,6 +1180,13 @@ class VideoCallManager {
);
}
Future<void> _disconnectLiveKitWithTimeout() {
return _disconnectLiveKitOnly().timeout(
const Duration(seconds: 3),
onTimeout: () {},
);
}
Future<void> _disconnectLiveKitOnly() async {
if (_room == null) return;
await _stopCallAudioSession();
@@ -1216,14 +1257,6 @@ class VideoCallManager {
);
}
bool _roomHasRemoteUser() {
final room = _room;
if (room == null) return false;
return videoCallRoomStillHasRemoteUser(
room.remoteParticipants.values.map((participant) => participant.identity),
);
}
bool get _canHandleCallAudio =>
_callStageEntered &&
!_liveKitDisposed &&
@@ -1261,12 +1294,14 @@ class VideoCallManager {
if (micOn) {
try {
await lp?.setMicrophoneEnabled(
true,
audioCaptureOptions: _micCaptureOptions,
);
await _audioSession.restartCapture();
await lp
?.setMicrophoneEnabled(
true,
audioCaptureOptions: _micCaptureOptions,
)
.timeout(const Duration(seconds: 8));
await _bindIosCallKitAudioEngine();
await _audioSession.restartCapture();
} catch (_) {
effectiveMic = false;
}
@@ -1309,15 +1344,24 @@ class VideoCallManager {
Future<void> _bindIosCallKitAudioEngine() async {
if (!Platform.isIOS) return;
try {
final calls = await FlutterCallkitIncoming.activeCalls();
if (calls.isEmpty) return;
} catch (_) {
return;
}
await _audioSession.startCallKitCapture();
}
/// CallKit activates the session on accept and again when the call is
/// marked connected. The second activation can stop AudioEngine after the
/// microphone track is already published, so recording has to start again.
Future<void> _restartIosCaptureIfMicOn() async {
if (!_iosMicCaptureReady) return;
// The plugin posts an audio-interruption ended notice in the same
// didActivate. Starting immediately loses to that notice.
await Future<void>.delayed(const Duration(milliseconds: 300));
if (!_iosMicCaptureReady) return;
await _audioSession.startCallKitCapture();
}
bool get _iosMicCaptureReady =>
Platform.isIOS && _snapshot.micEnabled && _callStageEntered;
Future<void> _startCallAudioSession() async {
if (!_canHandleCallAudio) return;
try {
@@ -1333,18 +1377,22 @@ class VideoCallManager {
/// Incoming iOS keeps CallKit up for the whole call so the microphone
/// session stays. Hang-up has to close that CallKit call.
///
/// An empty id used to match every active call, including the one the
/// callee just answered, and CallKit then deactivated the microphone.
Future<void> _dismissIosCallKitAfterHangup(String? conversationUuid) async {
if (!Platform.isIOS) return;
final uuid = conversationUuid?.trim().toLowerCase() ?? '';
if (uuid.isEmpty) return;
try {
final activeCalls = await FlutterCallkitIncoming.activeCalls();
if (activeCalls.isEmpty) return;
final uuid = conversationUuid?.trim().toLowerCase() ?? '';
for (final call in activeCalls) {
final handle = call.handle?.trim().toLowerCase();
final extraUuid =
call.extra?['conversationUuid']?.toString().trim().toLowerCase() ??
call.extra?['conversation_uuid']?.toString().trim().toLowerCase();
final matches = uuid.isEmpty || handle == uuid || extraUuid == uuid;
final matches = handle == uuid || extraUuid == uuid;
if (!matches) continue;
VideoCallRemoteEndRegistry.mark(call.id);
try {

View File

@@ -5,6 +5,7 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart
import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
import 'package:flutter/material.dart';
@@ -83,10 +84,19 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
}
if (_isCallSessionInactive(snapshot)) {
unawaited(_systemPipCoordinator.disarm());
if (_callScreenRouteVisible) {
if (isTransientIdleSnapshotWhileCallScreenVisible(
callScreenVisible: _callScreenRouteVisible,
conversationUuid: snapshot.conversationUuid,
needsManualReconnect: snapshot.linkIssue.needsManualReconnect,
connectError: snapshot.connectError,
)) {
// [VideoCallManager.startCall] briefly emits idle while replacing the room.
return;
}
if (_callScreenRouteVisible) {
emit(_sessionStateFromSnapshot(snapshot));
return;
}
_resetLobbyReadyTracking();
emit(const VideoCallSessionState());
unawaited(_restoreSystemUiAfterCall());
@@ -361,7 +371,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
_autoEnterInFlight = true;
try {
await _requestMicBeforeIncomingEnter();
await enterCallStage();
for (var attempt = 0; attempt < 3; attempt++) {
if (isClosed || state.callStageEntered) return;
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return;
await enterCallStage();
if (state.callStageEntered) return;
await Future<void>.delayed(Duration(milliseconds: 350 * (attempt + 1)));
}
} finally {
_autoEnterInFlight = false;
}
@@ -383,7 +400,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<bool> _requestMicPermissionForCall() async {
try {
final status = await Permission.microphone.request();
final current = await Permission.microphone.status;
if (current.isGranted) return true;
// The incoming-call activity can keep this future pending when the app
// was already open. The call has to leave the loading screen anyway.
final status = await Permission.microphone.request().timeout(
const Duration(seconds: 3),
onTimeout: () => PermissionStatus.denied,
);
return status.isGranted;
} catch (_) {
return false;
@@ -396,7 +420,8 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
if (_videoCallManager.callEndedNotified) return;
final inCall =
state.callStageEntered ||
state.connectPhase == VideoCallConnectPhase.connected;
state.connectPhase == VideoCallConnectPhase.connected ||
state.connectPhase == VideoCallConnectPhase.connecting;
if (!inCall) return;
_handlingRemoteCallEnd = true;

View File

@@ -20,7 +20,13 @@ class VideoCallAudioSessionCoordinator {
_isIos = isIos ?? Platform.isIOS,
_iosCallAudioSession = iosCallAudioSession ?? _invokeIosCallAudioSession,
_stopLocalRecording = stopLocalRecording ?? _invokeStopLocalRecording,
_startLocalRecording = startLocalRecording ?? _invokeStartLocalRecording;
_startLocalRecording =
startLocalRecording ?? _invokeStartLocalRecording {
if (iosCallAudioSession == null && _isIos) {
_activationListener = this;
_iosChannel.setMethodCallHandler(_onIosAudioSessionCall);
}
}
static const MethodChannel _iosChannel = MethodChannel(
'com.eatvioapp/call_audio_session',
@@ -34,6 +40,11 @@ class VideoCallAudioSessionCoordinator {
final IosCallAudioSessionHandler _iosCallAudioSession;
final IosAdmHandler _stopLocalRecording;
final IosAdmHandler _startLocalRecording;
static VideoCallAudioSessionCoordinator? _activationListener;
/// Called when CallKit activates the audio session, including the second
/// activation from `setCallConnected`.
Future<void> Function()? onIosAudioSessionActivated;
static final AudioSessionConfiguration callConfiguration =
AudioSessionConfiguration(
@@ -64,12 +75,34 @@ class VideoCallAudioSessionCoordinator {
return _iosChannel.invokeMethod<void>(method);
}
static Future<void> _invokeStopLocalRecording() {
return _webrtcChannel.invokeMethod<void>('stopLocalRecording');
static Future<void> _onIosAudioSessionCall(MethodCall call) async {
if (call.method != 'audioSessionActivated') return;
await _activationListener?.onIosAudioSessionActivated?.call();
}
static Future<void> _invokeStartLocalRecording() {
return _webrtcChannel.invokeMethod<void>('startLocalRecording');
static Future<void> _ensureWebRtcInitialized() async {
try {
await _webrtcChannel.invokeMethod<void>(
'initialize',
<String, dynamic>{'options': <String, dynamic>{}},
);
} catch (_) {}
}
static Future<void> _invokeStopLocalRecording() async {
await _ensureWebRtcInitialized();
await _webrtcChannel.invokeMethod<void>(
'stopLocalRecording',
<String, dynamic>{},
);
}
static Future<void> _invokeStartLocalRecording() async {
await _ensureWebRtcInitialized();
await _webrtcChannel.invokeMethod<void>(
'startLocalRecording',
<String, dynamic>{},
);
}
Future<void> start() async {
@@ -84,7 +117,7 @@ class VideoCallAudioSessionCoordinator {
await session.configure(callConfiguration);
_configured = true;
}
await session.setActive(true);
await session.setActive(true).timeout(const Duration(seconds: 5));
}
Future<bool> reactivate() async {
@@ -116,14 +149,11 @@ class VideoCallAudioSessionCoordinator {
}
/// flutter-webrtc 1.2 uses AudioEngine ADM. `isAudioEnabled` does not start
/// that engine. Incoming CallKit therefore needs `initAndStartRecording`
/// after the microphone track exists and CallKit has activated the session.
/// that engine. `initAndStartRecording` has to run after the microphone
/// track exists. Do not stop first: that tears the capture graph down and
/// the published track stays silent.
Future<void> startCallKitCapture() async {
if (!_isIos) return;
try {
await _stopLocalRecording();
} catch (_) {}
await Future<void>.delayed(const Duration(milliseconds: 120));
try {
await _startLocalRecording();
_iosActive = true;
@@ -133,6 +163,9 @@ class VideoCallAudioSessionCoordinator {
Future<void> stop() async {
if (_isIos) {
_iosActive = false;
try {
await _stopLocalRecording();
} catch (_) {}
try {
await _iosCallAudioSession('deactivate');
} catch (_) {}

View File

@@ -37,6 +37,7 @@ class VideoCallCallKitCoordinator {
final Map<String, CallKitParams> _callKitParamsById = {};
final Set<String> _acceptNavigationCompletedIds = {};
final Map<String, Future<void>> _acceptDeliveriesInFlight = {};
final Map<String, Future<void>> _acceptDeliveriesByConversation = {};
final Set<String> _suppressedEndCallKitIds = {};
String? _activeCallKitId;
bool _initialized = false;
@@ -107,12 +108,19 @@ class VideoCallCallKitCoordinator {
}
Future<void> _onAndroidCallPushMethodCall(MethodCall call) async {
if (call.method == 'onCallKitLaunchIntent') {
log(
'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native',
name: 'VideoCallCallKit',
);
await _handleAndroidCallKitLaunchIntent(call.arguments);
switch (call.method) {
case 'onCallKitLaunchIntent':
log(
'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native',
name: 'VideoCallCallKit',
);
await _handleAndroidCallKitLaunchIntent(call.arguments);
case 'onCallKitDecline':
log(
'VideoCallCallKitCoordinator: received onCallKitDecline from native',
name: 'VideoCallCallKit',
);
await _handleAndroidCallKitDecline(call.arguments);
}
}
@@ -226,6 +234,21 @@ class VideoCallCallKitCoordinator {
);
}
Future<void> _handleAndroidCallKitDecline(Object? arguments) async {
final data = _stringKeyedMap(arguments);
final extra = _stringKeyedMap(data['extra']);
final callKitId =
_parseString(data, const ['id', 'uuid']) ??
_parseString(extra, const ['conversationUuid', 'conversation_uuid']);
if (callKitId == null) return;
try {
await _androidCallPushChannel.invokeMethod<Object?>('drainPendingCallKitDecline');
} catch (_) {}
await _handleDecline(callKitId, eventExtra: extra);
}
void dispose() {
final observer = _androidResumeObserver;
if (observer != null) {
@@ -493,8 +516,9 @@ class VideoCallCallKitCoordinator {
return;
}
final callId = _parseCallId(extra);
await _deliverIncomingAccept(
callKitId: conversationUuid,
callKitId: callId ?? conversationUuid,
conversationUuid: conversationUuid,
counterpartName: _parseString(extra, const [
'counterpartName',
@@ -672,6 +696,13 @@ class VideoCallCallKitCoordinator {
name: 'VideoCallCallKit',
);
final conversationKey = conversationUuid.trim().toLowerCase();
final conversationDelivery = _acceptDeliveriesByConversation[conversationKey];
if (conversationDelivery != null) {
await conversationDelivery;
return;
}
_rememberPendingIncomingAccept(
callKitId: callKitId,
conversationUuid: conversationUuid,
@@ -698,12 +729,16 @@ class VideoCallCallKitCoordinator {
counterpartIsConsultant: counterpartIsConsultant,
);
_acceptDeliveriesInFlight[callKitId] = delivery;
_acceptDeliveriesByConversation[conversationKey] = delivery;
try {
await delivery;
} finally {
if (identical(_acceptDeliveriesInFlight[callKitId], delivery)) {
_acceptDeliveriesInFlight.remove(callKitId);
}
if (identical(_acceptDeliveriesByConversation[conversationKey], delivery)) {
_acceptDeliveriesByConversation.remove(conversationKey);
}
}
}
@@ -728,7 +763,15 @@ class VideoCallCallKitCoordinator {
_manager.notifyIncomingCallAccepted(conversationUuid, callId: callId),
);
// Android accept starts an ongoing-call service that holds the mic.
// Joining LiveKit while that service is still up never leaves the
// loading screen when the app was already in the foreground.
_suppressCallEnd(callKitId);
_suppressCallEnd(conversationUuid);
if (callId != null) _suppressCallEnd(callId);
try {
await _finishCallKitAfterAccept(callKitId);
await _waitForAcceptNavigationReady();
final navigated = await _openConnectingScreenAfterAccept(
@@ -764,8 +807,6 @@ class VideoCallCallKitCoordinator {
);
unawaited(_drainPendingIncomingAcceptWithRetries());
}
await _finishCallKitAfterAccept(callKitId);
} catch (error, stackTrace) {
log(
'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error',
@@ -777,9 +818,14 @@ class VideoCallCallKitCoordinator {
// must be ignored briefly. iOS keeps CallKit for the whole call: a
// later ENDED is a real hang-up and must not be swallowed.
if (Platform.isAndroid) {
final suppressedIds = <String>{
callKitId,
conversationUuid,
if (callId != null) callId,
};
unawaited(
Future<void>.delayed(const Duration(seconds: 2), () {
_suppressedEndCallKitIds.remove(callKitId);
_suppressedEndCallKitIds.removeAll(suppressedIds);
_acceptNavigationCompletedIds.remove(callKitId);
}),
);
@@ -815,6 +861,12 @@ class VideoCallCallKitCoordinator {
return;
}
_suppressCallEnd(pending.callKitId);
_suppressCallEnd(pending.conversationUuid);
try {
await _finishCallKitAfterAccept(pending.callKitId);
} catch (_) {}
await _waitForAcceptNavigationReady();
await _settleUiAfterResume();
@@ -837,9 +889,6 @@ class VideoCallCallKitCoordinator {
);
} catch (_) {}
}
try {
await _finishCallKitAfterAccept(pending.callKitId);
} catch (_) {}
}
/// After answer: Android dismisses the incoming notification. iOS keeps the
@@ -852,8 +901,32 @@ class VideoCallCallKitCoordinator {
} catch (_) {}
return;
}
_suppressedEndCallKitIds.add(callKitId);
_suppressCallEnd(callKitId);
await FlutterCallkitIncoming.endCall(callKitId);
if (!Platform.isAndroid) return;
await _waitUntilAndroidCallKitReleased();
}
/// Telecom keeps the microphone while the incoming connection is active.
/// Joining LiveKit before it is gone never leaves the loading screen when
/// the app was already in the foreground.
Future<void> _waitUntilAndroidCallKitReleased() async {
final deadline = DateTime.now().add(const Duration(milliseconds: 1500));
while (DateTime.now().isBefore(deadline)) {
try {
final calls = await FlutterCallkitIncoming.activeCalls();
if (calls.isEmpty) return;
} catch (_) {
return;
}
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
void _suppressCallEnd(String id) {
final trimmed = id.trim();
if (trimmed.isEmpty) return;
_suppressedEndCallKitIds.add(trimmed);
}
Future<void> _settleUiAfterResume() async {
@@ -918,8 +991,11 @@ class VideoCallCallKitCoordinator {
extra,
handle: params?.handle ?? callKitId,
);
final callId =
_parseCallId(extra) ?? (callKitId.contains('-') ? callKitId : null);
final callId = backendVideoCallId(
callId:
_parseCallId(extra) ?? (callKitId.contains('-') ? callKitId : null),
conversationUuid: conversationUuid,
);
final remoteEndDismissal =
callKitDeclineIsEndedPush(
cachedExtra: params?.extra,

View File

@@ -112,9 +112,10 @@ abstract final class VideoCallEndedPushHandler {
}
}
}
// When call_id is known, never fall back to conversation matching:
// a stale ended event must not close a newer ring in the same chat.
return false;
// A ring that already has its own call id must not be closed by a
// stale ended event for another call in the same chat. A legacy ring
// whose id is only the conversation still has to close.
if (_callHasDistinctCallId(call)) return false;
}
final uuid = conversationUuid?.trim().toLowerCase();
@@ -122,6 +123,18 @@ abstract final class VideoCallEndedPushHandler {
return callMatchesConversation(call, uuid);
}
/// True when this ring already carries a server call id. Conversation
/// fallback would close a newer call in the same chat.
static bool _callHasDistinctCallId(CallKitParams call) {
final extra = call.extra;
if (extra == null) return false;
for (final key in const ['call_id', 'callId']) {
final value = extra[key];
if (value is String && value.trim().isNotEmpty) return true;
}
return false;
}
static bool callMatchesConversation(
CallKitParams call,
String conversationUuid,

View File

@@ -18,15 +18,14 @@ bool shouldForceEndCallAfterPeerLeft({
return !videoCallRoomStillHasRemoteUser(remoteIdentities);
}
/// The participant who taps hang up must not report the end while the other
/// person is still in the room. That report and the remaining client's report
/// both see an empty room and each one rings the other user again.
/// Hang up reports the end the same way the web client does after it leaves
/// the room. The server then sends the ended chat message and push when no
/// other person is left in the room, which drops the call on the other devices.
bool shouldNotifyCallEnded({
required bool alreadyNotified,
required bool hasConversation,
required bool peerStillInRoom,
}) {
if (!hasConversation || alreadyNotified || peerStillInRoom) return false;
if (!hasConversation || alreadyNotified) return false;
return true;
}
@@ -91,3 +90,44 @@ bool shouldForceEndCallAfterRoomDisconnect({
return reason == lk.DisconnectReason.participantRemoved ||
reason == lk.DisconnectReason.roomDeleted;
}
/// Starting an incoming call must not run [endCall]. That dismisses the
/// CallKit call which just activated the microphone, and an empty
/// conversation id dismisses every active CallKit call.
bool shouldPreserveCallKitWhenStartingCall({
required String previousConversationUuid,
required String nextConversationUuid,
}) {
final previous = previousConversationUuid.trim();
final next = nextConversationUuid.trim();
if (previous.isEmpty) return true;
return previous == next;
}
/// `POST /decline` and `POST /ended` treat `call_id` as the session uuid.
/// The conversation uuid is a different id; sending it makes the request 404
/// and the other devices never hear about the hang-up.
String? backendVideoCallId({
required String? callId,
required String conversationUuid,
}) {
final id = callId?.trim() ?? '';
if (id.isEmpty) return null;
if (id == conversationUuid.trim()) return null;
return id;
}
/// [startCall] emits an empty idle snapshot while it replaces the room.
/// A real failure (lost link or a connect error) still has to reach the
/// visible call screen, otherwise the loading screen never leaves.
bool isTransientIdleSnapshotWhileCallScreenVisible({
required bool callScreenVisible,
required String conversationUuid,
required bool needsManualReconnect,
required String? connectError,
}) {
if (!callScreenVisible) return false;
if (needsManualReconnect) return false;
if (connectError != null && connectError.trim().isNotEmpty) return false;
return conversationUuid.trim().isEmpty;
}

View File

@@ -0,0 +1,81 @@
import 'dart:convert';
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
/// `video-call-declined` / `video-call-ended` on `private-new-chat-messages`.
///
/// The web client closes every other device from these events. A decline does
/// not create a chat message, so the open app has to read the socket itself.
class VideoCallSocketSignal {
const VideoCallSocketSignal({
required this.endsActiveCall,
this.conversationUuid,
this.callId,
this.reason,
});
final bool endsActiveCall;
final String? conversationUuid;
final String? callId;
final VideoCallEndReason? reason;
static VideoCallSocketSignal? tryParseDeclined(Object? raw) {
return _tryParse(raw, declined: true);
}
static VideoCallSocketSignal? tryParseEnded(Object? raw) {
return _tryParse(raw, declined: false);
}
static VideoCallSocketSignal? _tryParse(
Object? raw, {
required bool declined,
}) {
final data = _asMap(raw);
if (data == null) return null;
final conversationUuid = _string(data, const [
'conversation_uuid',
'conversationUuid',
]);
final callId = _string(data, const ['call_id', 'callId']);
if ((conversationUuid == null || conversationUuid.isEmpty) &&
(callId == null || callId.isEmpty)) {
return null;
}
final reason = declined
? VideoCallEndReason.declined
: VideoCallEndReason.tryParse(data['reason'] ?? data['status']);
final endsActiveCall = declined || reason != VideoCallEndReason.accepted;
return VideoCallSocketSignal(
endsActiveCall: endsActiveCall,
conversationUuid: conversationUuid,
callId: callId,
reason: reason,
);
}
static Map<String, dynamic>? _asMap(Object? raw) {
if (raw is Map) {
return raw.map((key, value) => MapEntry(key.toString(), value));
}
if (raw is! String || raw.trim().isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is Map) {
return decoded.map((key, value) => MapEntry(key.toString(), value));
}
} catch (_) {}
return null;
}
static String? _string(Map<String, dynamic> data, List<String> keys) {
for (final key in keys) {
final value = data[key];
if (value is String && value.trim().isNotEmpty) return value.trim();
}
return null;
}
}

View File

@@ -1,6 +1,6 @@
name: eatvio_app
description: A new Flutter project.
version: 1.3.64+153
version: 1.3.65+154
publish_to: 'none'
environment:

View File

@@ -0,0 +1,23 @@
import 'package:eatvio_app/core/device/device_hw_id_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('android install id stays the same for this install', () async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final preferences = await SharedPreferences.getInstance();
final first = await DeviceHwIdProvider.androidInstallId(
preferences: preferences,
);
final second = await DeviceHwIdProvider.androidInstallId(
preferences: preferences,
);
expect(first.length, greaterThanOrEqualTo(8));
expect(second, first);
expect(first.contains('-'), isTrue);
});
}

View File

@@ -1027,6 +1027,42 @@ void main() {
await reconnectController.close();
},
);
test('video-call-declined closes the call on this device', () async {
final declined = StreamController<ChannelReadEvent>.broadcast();
when(mockWsManager.newMessagesStream).thenReturn(null);
when(mockWsManager.reconnected).thenAnswer((_) => const Stream.empty());
when(
mockWsManager.videoCallDeclinedStream,
).thenAnswer((_) => declined.stream);
when(mockWsManager.videoCallEndedStream).thenReturn(null);
final dismissed = <String?>[];
final ended = <String?>[];
chatsCubit.dismissIncomingVideoCall = (uuid) async {
dismissed.add(uuid);
};
chatsCubit.endActiveVideoCall = ({conversationUuid, callId}) async {
ended.add(conversationUuid);
expect(callId, 'call-1');
};
await chatsCubit.connectToWebSocket();
declined.add(
_FakeChannelReadEvent(
jsonEncode(<String, dynamic>{
'call_id': 'call-1',
'conversation_uuid': 'conv-1',
'status': 'declined',
}),
),
);
await Future<void>.delayed(Duration.zero);
expect(dismissed, ['conv-1']);
expect(ended, ['conv-1']);
await declined.close();
});
});
}

View File

@@ -1,4 +1,5 @@
import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
@@ -28,7 +29,7 @@ void main() {
expect(calls, ['activate', 'activate', 'restart', 'deactivate']);
});
test('startCallKitCapture restarts the AudioEngine ADM', () async {
test('startCallKitCapture starts the AudioEngine ADM without stopping', () async {
final adm = <String>[];
final coordinator = VideoCallAudioSessionCoordinator(
isIos: true,
@@ -40,7 +41,45 @@ void main() {
await coordinator.startCallKitCapture();
expect(coordinator.isActive, isTrue);
expect(adm, ['stop', 'start']);
expect(adm, ['start']);
});
test('stop stops the AudioEngine ADM', () async {
final adm = <String>[];
final coordinator = VideoCallAudioSessionCoordinator(
isIos: true,
iosCallAudioSession: (_) async {},
stopLocalRecording: () async => adm.add('stop'),
startLocalRecording: () async => adm.add('start'),
);
await coordinator.startCallKitCapture();
await coordinator.stop();
expect(coordinator.isActive, isFalse);
expect(adm, ['start', 'stop']);
});
test('forwards a later CallKit audio activation', () async {
TestWidgetsFlutterBinding.ensureInitialized();
final coordinator = VideoCallAudioSessionCoordinator(isIos: true);
var activations = 0;
coordinator.onIosAudioSessionActivated = () async {
activations++;
};
const codec = StandardMethodCodec();
final data = codec.encodeMethodCall(
const MethodCall('audioSessionActivated'),
);
await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.handlePlatformMessage(
'com.eatvioapp/call_audio_session',
data,
(_) {},
);
expect(activations, 1);
});
test('stop clears the active session when deactivate fails', () async {

View File

@@ -75,6 +75,23 @@ void main() {
isFalse,
);
});
test('a legacy ring without call_id still matches the conversation', () {
const call = CallKitParams(
id: conversation,
handle: conversation,
extra: <String, dynamic>{'conversationUuid': conversation},
);
expect(
VideoCallEndedPushHandler.callMatches(
call: call,
conversationUuid: conversation,
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
),
isTrue,
);
});
});
group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () {

View File

@@ -66,23 +66,11 @@ void main() {
);
});
test('does not report the end while the other user is still in the room', () {
test('reports the end so the other devices drop the call', () {
expect(
shouldNotifyCallEnded(
alreadyNotified: false,
hasConversation: true,
peerStillInRoom: true,
),
isFalse,
);
});
test('reports the end when this client is the last participant', () {
expect(
shouldNotifyCallEnded(
alreadyNotified: false,
hasConversation: true,
peerStillInRoom: false,
),
isTrue,
);
@@ -90,7 +78,13 @@ void main() {
shouldNotifyCallEnded(
alreadyNotified: true,
hasConversation: true,
peerStillInRoom: false,
),
isFalse,
);
expect(
shouldNotifyCallEnded(
alreadyNotified: false,
hasConversation: false,
),
isFalse,
);
@@ -118,6 +112,78 @@ void main() {
);
});
test('an incoming start does not tear down the accepted CallKit call', () {
expect(
shouldPreserveCallKitWhenStartingCall(
previousConversationUuid: '',
nextConversationUuid: 'conv-1',
),
isTrue,
);
expect(
shouldPreserveCallKitWhenStartingCall(
previousConversationUuid: 'conv-1',
nextConversationUuid: 'conv-1',
),
isTrue,
);
expect(
shouldPreserveCallKitWhenStartingCall(
previousConversationUuid: 'conv-old',
nextConversationUuid: 'conv-1',
),
isFalse,
);
});
test('the conversation uuid is not sent as the session call id', () {
expect(
backendVideoCallId(callId: 'conv-1', conversationUuid: 'conv-1'),
isNull,
);
expect(
backendVideoCallId(
callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
conversationUuid: 'conv-1',
),
'0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b',
);
expect(
backendVideoCallId(callId: ' ', conversationUuid: 'conv-1'),
isNull,
);
});
test('a lost connection on the visible call screen is not ignored', () {
expect(
isTransientIdleSnapshotWhileCallScreenVisible(
callScreenVisible: true,
conversationUuid: '',
needsManualReconnect: false,
connectError: null,
),
isTrue,
);
expect(
isTransientIdleSnapshotWhileCallScreenVisible(
callScreenVisible: true,
conversationUuid: 'conv-1',
needsManualReconnect: true,
connectError: 'timeout',
),
isFalse,
);
expect(
isTransientIdleSnapshotWhileCallScreenVisible(
callScreenVisible: false,
conversationUuid: '',
needsManualReconnect: false,
connectError: null,
),
isFalse,
);
});
test('a token is not requested again right after leaving', () {
final leftAt = DateTime(2026, 10, 16, 12);
expect(

View File

@@ -0,0 +1,49 @@
import 'dart:convert';
import 'package:eatvio_app/features/video_calls/services/video_call_end_reason.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_socket_signal.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
test('decline closes the caller call and the other ringing devices', () {
final signal = VideoCallSocketSignal.tryParseDeclined(
jsonEncode(<String, dynamic>{
'call_id': 'call-1',
'conversation_uuid': 'conv-1',
'status': 'declined',
}),
);
expect(signal, isNotNull);
expect(signal!.endsActiveCall, isTrue);
expect(signal.conversationUuid, 'conv-1');
expect(signal.callId, 'call-1');
expect(signal.reason, VideoCallEndReason.declined);
});
test('ended closes an active call', () {
final signal = VideoCallSocketSignal.tryParseEnded(<String, dynamic>{
'conversation_uuid': 'conv-1',
'reason': 'cancelled',
});
expect(signal?.endsActiveCall, isTrue);
expect(signal?.reason, VideoCallEndReason.cancelled);
});
test('an accept marker does not tear down the call this device joined', () {
final signal = VideoCallSocketSignal.tryParseEnded(<String, dynamic>{
'call_id': 'call-1',
'reason': 'accepted',
});
expect(signal?.endsActiveCall, isFalse);
});
test('ignores a payload without a call or a conversation', () {
expect(
VideoCallSocketSignal.tryParseDeclined(<String, dynamic>{'status': 'declined'}),
isNull,
);
});
}