From d8d7a211bde656bb3983472a60823d191742d4a6 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 22 Oct 2026 14:56:15 +0300 Subject: [PATCH] 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 --- .../app/CallKitAcceptIntentMapper.kt | 30 ++-- .../com/eatvioapp/app/CallKitIntentBridge.kt | 6 + .../kotlin/com/eatvioapp/app/MainActivity.kt | 36 +++++ .../app/PendingCallKitDeclineBridge.kt | 44 ++++++ .../com/eatvioapp/app/VideoCallPushBridge.kt | 5 +- ios/Runner/AppDelegate.swift | 10 ++ lib/core/device/device_hw_id_provider.dart | 62 +++++++- lib/core/firebase_api.dart | 56 ++++--- lib/core/websockets/ws_manager.dart | 8 + .../bloc/chats_cubit/chats_cubit.dart | 55 +++++++ .../managers/video_call_manager.dart | 142 ++++++++++++------ .../video_call_session_cubit.dart | 33 +++- .../video_call_audio_session_coordinator.dart | 57 +++++-- .../video_call_callkit_coordinator.dart | 108 +++++++++++-- .../video_call_ended_push_handler.dart | 19 ++- .../services/video_call_peer_departure.dart | 50 +++++- .../services/video_call_socket_signal.dart | 81 ++++++++++ pubspec.yaml | 2 +- .../device/device_hw_id_provider_test.dart | 23 +++ .../presentation/bloc/chats_cubit_test.dart | 36 +++++ ...o_call_audio_session_coordinator_test.dart | 43 +++++- .../video_call_ended_push_handler_test.dart | 17 +++ .../video_call_peer_departure_test.dart | 94 ++++++++++-- .../video_call_socket_signal_test.dart | 49 ++++++ 24 files changed, 924 insertions(+), 142 deletions(-) create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitDeclineBridge.kt create mode 100644 lib/features/video_calls/services/video_call_socket_signal.dart create mode 100644 test/core/device/device_hw_id_provider_test.dart create mode 100644 test/features/video_calls/services/video_call_socket_signal_test.dart diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt index 3b6d3732..248d1d1f 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt @@ -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? { + if (!action.contains(CallkitConstants.ACTION_CALL_DECLINE)) return null + return payloadMap(data, action) } fun payloadFromIntent(intent: Intent?): Map? { @@ -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 { 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 - } } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt index 070462bc..95e887fe 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt @@ -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) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index dc110843..4ab92064 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitDeclineBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitDeclineBridge.kt new file mode 100644 index 00000000..e50a23b0 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitDeclineBridge.kt @@ -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? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + + fun store(payload: Map) { + pendingPayload = payload + Log.i(TAG, "stored pending decline id=${payload["id"]}") + } + + fun peek(): Map? = pendingPayload + + fun drain(): Map? { + 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) + } + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index d901ada3..cf915ba8 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -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 diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 2b4e868f..803786ab 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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) { diff --git a/lib/core/device/device_hw_id_provider.dart b/lib/core/device/device_hw_id_provider.dart index 383aac2b..b94b88b9 100644 --- a/lib/core/device/device_hw_id_provider.dart +++ b/lib/core/device/device_hw_id_provider.dart @@ -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 current() async { - final deviceInfo = DeviceInfoPlugin(); + static const androidInstallIdKey = 'eatvio.android_install_hw_id'; + static const androidTokenMigratedKey = 'eatvio.android_hw_id_token_migrated'; + + static Future 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 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 androidTokenNeedsReregister({ + SharedPreferences? preferences, + }) async { + if (!Platform.isAndroid) return false; + final prefs = preferences ?? await SharedPreferences.getInstance(); + return prefs.getBool(androidTokenMigratedKey) != true; + } + + static Future 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.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)}'; + } } diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index d18024b3..19c7cbc7 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -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 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().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().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() + .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().post( '/api/profile/notification/token/voip/register', data: { 'platform': 'ios', - 'hw_id': (await deviceInfo.iosInfo).identifierForVendor, + 'hw_id': hwId, 'token': token, }, ).timeout( diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index 2d79ad63..b887aaf6 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -50,6 +50,14 @@ class WsManager { Stream? get newMessagesStream => _privateMessagesChannel?.bind('App\\Events\\ChatMessageUpdated'); + /// The callee rejected the ring. Broadcast to the caller and the callee. + Stream? get videoCallDeclinedStream => + _privateMessagesChannel?.bind('video-call-declined'); + + /// The call was cancelled or hung up. Same channel the web client uses. + Stream? get videoCallEndedStream => + _privateMessagesChannel?.bind('video-call-ended'); + Stream? get newNotificationsStream => _privateNotificationChannel?.bind('Illuminate\\Notifications\\Events\\BroadcastNotificationCreated'); Stream? get directChatStream => _directChatStream; diff --git a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart index e166c84b..b5b76ccd 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -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 { StreamSubscription? newIncomingMessagesSubscription; StreamSubscription? newLastMessageSubscription; StreamSubscription? reconnectedSubscription; + StreamSubscription? videoCallDeclinedSubscription; + StreamSubscription? videoCallEndedSubscription; final Set _presentedVideoCallMessageIds = {}; /// Test seam. Production uses CallKit. Future Function(VideoCallPushPayload payload)? presentIncomingVideoCall; Future Function(String? conversationUuid)? dismissIncomingVideoCall; + Future Function({String? conversationUuid, String? callId})? + endActiveVideoCall; ChatsCubit({ required this.chatRepository, @@ -55,6 +60,26 @@ class ChatsCubit extends Cubit { _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 { } } + Future _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 _dismissIncomingVideoCallUi(String? conversationUuid) async { try { final dismiss = dismissIncomingVideoCall; @@ -308,6 +361,8 @@ class ChatsCubit extends Cubit { @override Future close() { newIncomingMessagesSubscription?.cancel(); + videoCallDeclinedSubscription?.cancel(); + videoCallEndedSubscription?.cancel(); reconnectedSubscription?.cancel(); return super.close(); } diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 36962209..ff531fc2 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -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 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((_) { + ..on((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 _disconnectLiveKitWithTimeout() { + return _disconnectLiveKitOnly().timeout( + const Duration(seconds: 3), + onTimeout: () {}, + ); + } + Future _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 _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 _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.delayed(const Duration(milliseconds: 300)); + if (!_iosMicCaptureReady) return; + await _audioSession.startCallKitCapture(); + } + + bool get _iosMicCaptureReady => + Platform.isIOS && _snapshot.micEnabled && _callStageEntered; + Future _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 _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 { diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 088c9177..adb57bb0 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -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 { } 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 { _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.delayed(Duration(milliseconds: 350 * (attempt + 1))); + } } finally { _autoEnterInFlight = false; } @@ -383,7 +400,14 @@ class VideoCallSessionCubit extends BaseCubit { Future _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 { 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; diff --git a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart index c65040ac..910b7cad 100644 --- a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart +++ b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart @@ -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 Function()? onIosAudioSessionActivated; static final AudioSessionConfiguration callConfiguration = AudioSessionConfiguration( @@ -64,12 +75,34 @@ class VideoCallAudioSessionCoordinator { return _iosChannel.invokeMethod(method); } - static Future _invokeStopLocalRecording() { - return _webrtcChannel.invokeMethod('stopLocalRecording'); + static Future _onIosAudioSessionCall(MethodCall call) async { + if (call.method != 'audioSessionActivated') return; + await _activationListener?.onIosAudioSessionActivated?.call(); } - static Future _invokeStartLocalRecording() { - return _webrtcChannel.invokeMethod('startLocalRecording'); + static Future _ensureWebRtcInitialized() async { + try { + await _webrtcChannel.invokeMethod( + 'initialize', + {'options': {}}, + ); + } catch (_) {} + } + + static Future _invokeStopLocalRecording() async { + await _ensureWebRtcInitialized(); + await _webrtcChannel.invokeMethod( + 'stopLocalRecording', + {}, + ); + } + + static Future _invokeStartLocalRecording() async { + await _ensureWebRtcInitialized(); + await _webrtcChannel.invokeMethod( + 'startLocalRecording', + {}, + ); } Future 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 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 startCallKitCapture() async { if (!_isIos) return; - try { - await _stopLocalRecording(); - } catch (_) {} - await Future.delayed(const Duration(milliseconds: 120)); try { await _startLocalRecording(); _iosActive = true; @@ -133,6 +163,9 @@ class VideoCallAudioSessionCoordinator { Future stop() async { if (_isIos) { _iosActive = false; + try { + await _stopLocalRecording(); + } catch (_) {} try { await _iosCallAudioSession('deactivate'); } catch (_) {} diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 937318f9..f53930f3 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -37,6 +37,7 @@ class VideoCallCallKitCoordinator { final Map _callKitParamsById = {}; final Set _acceptNavigationCompletedIds = {}; final Map> _acceptDeliveriesInFlight = {}; + final Map> _acceptDeliveriesByConversation = {}; final Set _suppressedEndCallKitIds = {}; String? _activeCallKitId; bool _initialized = false; @@ -107,12 +108,19 @@ class VideoCallCallKitCoordinator { } Future _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 _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('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 = { + callKitId, + conversationUuid, + if (callId != null) callId, + }; unawaited( Future.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 _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.delayed(const Duration(milliseconds: 100)); + } + } + + void _suppressCallEnd(String id) { + final trimmed = id.trim(); + if (trimmed.isEmpty) return; + _suppressedEndCallKitIds.add(trimmed); } Future _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, diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart index e0c67b71..8a6c6191 100644 --- a/lib/features/video_calls/services/video_call_ended_push_handler.dart +++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart @@ -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, diff --git a/lib/features/video_calls/services/video_call_peer_departure.dart b/lib/features/video_calls/services/video_call_peer_departure.dart index 7db7ba16..86d6f697 100644 --- a/lib/features/video_calls/services/video_call_peer_departure.dart +++ b/lib/features/video_calls/services/video_call_peer_departure.dart @@ -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; +} diff --git a/lib/features/video_calls/services/video_call_socket_signal.dart b/lib/features/video_calls/services/video_call_socket_signal.dart new file mode 100644 index 00000000..1978208f --- /dev/null +++ b/lib/features/video_calls/services/video_call_socket_signal.dart @@ -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? _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 data, List keys) { + for (final key in keys) { + final value = data[key]; + if (value is String && value.trim().isNotEmpty) return value.trim(); + } + return null; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 0f2c7383..d737573a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/core/device/device_hw_id_provider_test.dart b/test/core/device/device_hw_id_provider_test.dart new file mode 100644 index 00000000..c08aece7 --- /dev/null +++ b/test/core/device/device_hw_id_provider_test.dart @@ -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({}); + 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); + }); +} diff --git a/test/features/chat/presentation/bloc/chats_cubit_test.dart b/test/features/chat/presentation/bloc/chats_cubit_test.dart index 755164eb..079ca3c1 100644 --- a/test/features/chat/presentation/bloc/chats_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chats_cubit_test.dart @@ -1027,6 +1027,42 @@ void main() { await reconnectController.close(); }, ); + + test('video-call-declined closes the call on this device', () async { + final declined = StreamController.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 = []; + final ended = []; + 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({ + 'call_id': 'call-1', + 'conversation_uuid': 'conv-1', + 'status': 'declined', + }), + ), + ); + await Future.delayed(Duration.zero); + + expect(dismissed, ['conv-1']); + expect(ended, ['conv-1']); + await declined.close(); + }); }); } diff --git a/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart b/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart index 1621785c..6cf009ae 100644 --- a/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart +++ b/test/features/video_calls/services/video_call_audio_session_coordinator_test.dart @@ -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 = []; 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 = []; + 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 { diff --git a/test/features/video_calls/services/video_call_ended_push_handler_test.dart b/test/features/video_calls/services/video_call_ended_push_handler_test.dart index 9bac508a..3f5e0d2c 100644 --- a/test/features/video_calls/services/video_call_ended_push_handler_test.dart +++ b/test/features/video_calls/services/video_call_ended_push_handler_test.dart @@ -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: {'conversationUuid': conversation}, + ); + + expect( + VideoCallEndedPushHandler.callMatches( + call: call, + conversationUuid: conversation, + callId: '0199a0b1-7c2d-7e3f-8a4b-5c6d7e8f9a0b', + ), + isTrue, + ); + }); }); group('VideoCallEndedPushHandler.dismissIncomingCallUi (CallKit)', () { diff --git a/test/features/video_calls/services/video_call_peer_departure_test.dart b/test/features/video_calls/services/video_call_peer_departure_test.dart index 30a9c38b..216e330b 100644 --- a/test/features/video_calls/services/video_call_peer_departure_test.dart +++ b/test/features/video_calls/services/video_call_peer_departure_test.dart @@ -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( diff --git a/test/features/video_calls/services/video_call_socket_signal_test.dart b/test/features/video_calls/services/video_call_socket_signal_test.dart new file mode 100644 index 00000000..889b44c6 --- /dev/null +++ b/test/features/video_calls/services/video_call_socket_signal_test.dart @@ -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({ + '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({ + '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({ + 'call_id': 'call-1', + 'reason': 'accepted', + }); + + expect(signal?.endsActiveCall, isFalse); + }); + + test('ignores a payload without a call or a conversation', () { + expect( + VideoCallSocketSignal.tryParseDeclined({'status': 'declined'}), + isNull, + ); + }); +}