From 8cb3a184b022585b5ba236e5544232373b42aa4c Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 21 Oct 2026 16:24:23 +0300 Subject: [PATCH] feat(video_calls): ios mic fix, tokens clearing fix --- ios/Runner/AppDelegate.swift | 276 +++++++++++++++--- .../menu/presentation/pages/menu_screen.dart | 13 +- .../managers/video_call_manager.dart | 32 +- .../video_call_callkit_coordinator.dart | 102 +++++-- .../video_call_ended_push_handler.dart | 13 + pubspec.yaml | 2 +- .../video_call_ended_push_handler_test.dart | 13 + 7 files changed, 375 insertions(+), 76 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index b340b48c..5d78ff99 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -20,8 +20,9 @@ import flutter_foreground_task private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" private let callAudioSessionChannelName = "com.eatvioapp/call_audio_session" private let appUrlScheme = "eatvio" - /// CallKit is only the ringing UI. After accept the in-app call continues, - /// so the audio session must be handed back to WebRTC when CallKit deactivates. + /// If CallKit ends while the in-app call must continue, take the audio + /// session back after iOS releases it. Incoming accept keeps CallKit up, + /// so this is only for an unexpected teardown. private var resumeAudioAfterCallKitEnds = false /// The push handler and the Flutter channel touch this map on different /// queues. The push writes the id before CallKit has the call, so the @@ -29,9 +30,16 @@ import flutter_foreground_task private var reservedIncomingCallIds: [String: Date] = [:] private let incomingCallClaimLock = NSLock() private let incomingCallClaimTtl: TimeInterval = 90 + /// Cancelled / declined server call ids. A late `video_call_started` with the + /// same id must not show ringing again (same TTL as Android / Dart). + private var tombstonedCallIds: [String: Date] = [:] + private let callTombstoneTtl: TimeInterval = 120 private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip") private var voipCallReporterStorage: VoipCallReporter? private var audioRestartGeneration = 0 + /// CallKit owns AVAudioSession from answer until the CallKit call ends. + /// WebRTC must not call setActive(true) on top of that session. + private var callKitOwnsAudioSession = false private var voipCallReporter: VoipCallReporter { if let voipCallReporterStorage { @@ -187,14 +195,19 @@ import flutter_foreground_task ] ) if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty { + var extra: [String: Any] = ["conversationUuid": conversationUuid] + if let rawCallId = queryItems.first(where: { $0.name == "call_id" })?.value { + let serverCallId = rawCallId.trimmingCharacters(in: .whitespacesAndNewlines) + if !serverCallId.isEmpty { + extra["call_id"] = serverCallId + } + } voipDiagnosticsChannel?.invokeMethod( "acceptedVoipCall", arguments: [ "id": callKitId, "handle": conversationUuid, - "extra": [ - "conversationUuid": conversationUuid - ], + "extra": extra, ] ) } @@ -223,6 +236,78 @@ import flutter_foreground_task return (String(conversationUuid[prefixRange]) + suffix).lowercased() } + /// Canonical CallKit id: prefer backend `call_id`, then legacy message-derived + /// uuid, then conversation uuid (same resolve order as Dart `VideoCallKitId`). + private func resolveCallKitId( + serverCallId: String?, + conversationUuid: String, + messageId: UInt64? + ) -> (id: String, source: String) { + if let serverCallId, !serverCallId.isEmpty { + return (serverCallId, "call_id") + } + if let derived = derivedCallKitId( + conversationUuid: conversationUuid, + messageId: messageId + ) { + return (derived, "conversation_and_message") + } + if UUID(uuidString: conversationUuid) != nil { + return (conversationUuid.lowercased(), "conversation") + } + return (UUID().uuidString.lowercased(), "generated") + } + + private func voipServerCallId(from dict: [AnyHashable: Any]) -> String? { + for key in ["call_id", "callId"] { + if let value = dict[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return nil + } + + private func voipEndReason(from dict: [AnyHashable: Any]) -> String? { + for key in ["reason", "end_reason", "endReason"] { + if let value = dict[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed.lowercased() + } + } + } + return nil + } + + private func pruneCallTombstones(now: Date = Date()) { + tombstonedCallIds = tombstonedCallIds.filter { + now.timeIntervalSince($0.value) < callTombstoneTtl + } + } + + private func markCallTombstone(_ callId: String?) { + let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard !normalized.isEmpty else { return } + pruneCallTombstones() + tombstonedCallIds[normalized] = Date() + DispatchQueue.main.async { [weak self] in + self?.voipDiagnosticsChannel?.invokeMethod( + "tombstoneCallId", + arguments: ["id": normalized] + ) + } + } + + private func isCallTombstoned(_ callId: String?) -> Bool { + let normalized = callId?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + guard !normalized.isEmpty else { return false } + pruneCallTombstones() + return tombstonedCallIds[normalized] != nil + } + private func voipMessageId(_ value: Any?) -> UInt64? { if let number = value as? NSNumber { let parsed = number.uint64Value @@ -356,10 +441,15 @@ import flutter_foreground_task var components = URLComponents() components.scheme = appUrlScheme components.host = "video-call" - components.queryItems = [ + var queryItems = [ URLQueryItem(name: "callKitId", value: call.data.uuid), URLQueryItem(name: "conversationUuid", value: conversationUuid) ] + let serverCallId = extraServerCallId(call.data.extra) + if !serverCallId.isEmpty { + queryItems.append(URLQueryItem(name: "call_id", value: serverCallId)) + } + components.queryItems = queryItems guard let url = components.url else { recordVoipDiagnostic( @@ -512,16 +602,27 @@ import flutter_foreground_task } } - /// An ended VoIP push has no message id, so its CallKit id is the - /// conversation uuid, not the ringing call. End that conversation only. - /// The first endCall while the push flag is set closes the call this push - /// just reported; the next ones close the call that is actually ringing. - private func endCallsForEndedPush(reportedId: String, conversationUuid: String) { + /// Ends only the ringing CallKit call that matches `serverCallId` when the + /// backend sent one. Conversation fallback is legacy-only: a stale ended + /// push must not close a newer ring in the same chat. + private func endCallsForEndedPush( + reportedId: String, + conversationUuid: String, + serverCallId: String? = nil, + reason: String? = nil + ) { guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else { releaseIncomingCallClaim(reportedId) return } let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedServerCallId = serverCallId? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let hasServerCallId = !normalizedServerCallId.isEmpty + if hasServerCallId { + markCallTombstone(normalizedServerCallId) + } + var ids: [String] = [] func append(_ id: String) { let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) @@ -536,6 +637,16 @@ import flutter_foreground_task let callId = (call["id"] as? String) ?? "" let handle = (call["handle"] as? String) ?? "" let extraUuid = extraConversationUuid(call["extra"]) + let extraCallId = extraServerCallId(call["extra"]) + if hasServerCallId { + let matchesServerCall = + callId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame + || extraCallId.caseInsensitiveCompare(normalizedServerCallId) == .orderedSame + if matchesServerCall { + append(callId) + } + continue + } let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame let sameConversation = !conversation.isEmpty && ( handle.caseInsensitiveCompare(conversation) == .orderedSame @@ -558,12 +669,35 @@ import flutter_foreground_task if !conversation.isEmpty { endExtra["conversationUuid"] = conversation } + if hasServerCallId { + endExtra["call_id"] = normalizedServerCallId + } + if let reason, !reason.isEmpty { + endExtra["reason"] = reason + } endData.extra = endExtra as NSDictionary plugin.endCall(endData) releaseIncomingCallClaim(id) } } + private func activeCallId(matchingServerCallId serverCallId: String) -> String? { + let normalized = serverCallId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return nil } + let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? [] + for call in calls { + let callId = (call["id"] as? String) ?? "" + guard !callId.isEmpty else { continue } + let extraCallId = extraServerCallId(call["extra"]) + if callId.caseInsensitiveCompare(normalized) == .orderedSame + || extraCallId.caseInsensitiveCompare(normalized) == .orderedSame + { + return callId + } + } + return nil + } + private func activeCallId(forConversation conversationUuid: String) -> String? { let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) guard !conversation.isEmpty else { return nil } @@ -582,21 +716,24 @@ import flutter_foreground_task return nil } - private func extraConversationUuid(_ extra: Any?) -> String { - let map: [String: Any] + private func extraMap(_ extra: Any?) -> [String: Any] { if let typed = extra as? [String: Any] { - map = typed - } else if let object = extra as? NSDictionary { + return typed + } + if let object = extra as? NSDictionary { var converted: [String: Any] = [:] for (key, value) in object { if let key = key as? String { converted[key] = value } } - map = converted - } else { - return "" + return converted } + return [:] + } + + private func extraConversationUuid(_ extra: Any?) -> String { + let map = extraMap(extra) for key in ["conversationUuid", "conversation_uuid"] { if let value = map[key] as? String { let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -608,6 +745,19 @@ import flutter_foreground_task return "" } + private func extraServerCallId(_ extra: Any?) -> String { + let map = extraMap(extra) + for key in ["call_id", "callId"] { + if let value = map[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return "" + } + func pushRegistry( _ registry: PKPushRegistry, didUpdate credentials: PKPushCredentials, @@ -648,11 +798,16 @@ import flutter_foreground_task .compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") } let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) }) + let serverCallId = voipServerCallId(from: dict) + let endReason = voipEndReason(from: dict) if isEndedPush { + markCallTombstone(serverCallId) recordVoipDiagnostic( "voip_end_or_cancel_received", data: [ "markers": pushMarkers, + "call_id": serverCallId ?? "", + "reason": endReason ?? "", "payload": dict, ] ) @@ -678,23 +833,28 @@ import flutter_foreground_task ?? "" let trimmedConversationUuid = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) let parsedMessageId = voipMessageId(dict["message_id"]) + let tombstonedIncoming = !isEndedPush && isCallTombstoned(serverCallId) let id: String let callKitIdSourceName: String - if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) { + if isEndedPush, let serverCallId, + let existing = activeCallId(matchingServerCallId: serverCallId) + { + id = existing + callKitIdSourceName = "active_call_id" + } else if isEndedPush, serverCallId == nil, + let existing = activeCallId(forConversation: trimmedConversationUuid) + { + // Legacy ended push without call_id. id = existing callKitIdSourceName = "active_conversation" - } else if let derived = derivedCallKitId( - conversationUuid: trimmedConversationUuid, - messageId: parsedMessageId - ) { - id = derived - callKitIdSourceName = "conversation_and_message" - } else if UUID(uuidString: trimmedConversationUuid) != nil { - id = trimmedConversationUuid.lowercased() - callKitIdSourceName = "conversation" } else { - id = UUID().uuidString.lowercased() - callKitIdSourceName = "generated" + let resolved = resolveCallKitId( + serverCallId: serverCallId, + conversationUuid: trimmedConversationUuid, + messageId: parsedMessageId + ) + id = resolved.id + callKitIdSourceName = resolved.source } let isVideo = (dict["isVideo"] as? Bool) ?? (dict["is_video"] as? Bool) @@ -704,6 +864,12 @@ import flutter_foreground_task if !trimmedConversationUuid.isEmpty { extra["conversationUuid"] = trimmedConversationUuid } + if let serverCallId, !serverCallId.isEmpty { + extra["call_id"] = serverCallId + } + if let endReason, !endReason.isEmpty { + extra["reason"] = endReason + } if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String ?? dict["avatarUrl"] as? String ?? dict["avatar_url"] as? String @@ -714,6 +880,8 @@ import flutter_foreground_task } if let messageId = dict["message_id"] as? String { extra["messageId"] = messageId + } else if let messageId = parsedMessageId { + extra["messageId"] = String(messageId) } if let roomName = dict["room_name"] as? String { extra["roomName"] = roomName @@ -736,12 +904,16 @@ import flutter_foreground_task // Closing this report looks like a user decline. Dart must not tell the // backend, or the server sends another ended VoIP and the other phone // shows it as a new incoming call. - if isEndedPush { + if isEndedPush || tombstonedIncoming { extra["endedPush"] = true } // An ended push with no ringing call still has to be reported. That // report is closed at once, so it must not leave a trace anywhere. - let isPhantomEndedReport = isEndedPush && callKitIdSourceName != "active_conversation" + // Tombstoned late incoming is treated the same way. + let matchedActiveRing = + callKitIdSourceName == "active_call_id" + || callKitIdSourceName == "active_conversation" + let isPhantomEndedReport = (isEndedPush && !matchedActiveRing) || tombstonedIncoming let data = flutter_callkit_incoming.Data( id: id, @@ -756,7 +928,7 @@ import flutter_foreground_task data.extra = extra as NSDictionary data.missedNotificationSubtitle = "Пропущенный видеозвонок" data.missedNotificationCallbackText = "Перезвонить" - data.isShowMissedCallNotification = !isEndedPush + data.isShowMissedCallNotification = !(isEndedPush || tombstonedIncoming) data.includesCallsInRecents = !isPhantomEndedReport // The plugin activates AVAudioSession itself on answer and again 1.2s // later. That drops the ringing UI and leaves one side silent. CallKit @@ -772,13 +944,16 @@ import flutter_foreground_task _ = reserveIncomingCall(id) NSLog( - "[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)" + "[EatvioVoIP] report incoming call id=\(id) call_id=\(serverCallId ?? "-") conversation=\(conversationUuid) reason=\(endReason ?? "-") state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)" ) recordVoipDiagnostic( "voip_incoming_received", data: [ "callKitId": id, "callKitIdSource": callKitIdSourceName, + "call_id": serverCallId ?? "", + "reason": endReason ?? "", + "tombstoned": tombstonedIncoming, "conversationUuid": conversationUuid, "nameCaller": nameCaller, "isVideo": isVideo, @@ -792,7 +967,9 @@ import flutter_foreground_task callKitId: id, callKitIdSourceName: callKitIdSourceName, conversationUuid: conversationUuid, - endAfterReport: isEndedPush, + serverCallId: serverCallId, + reason: endReason, + endAfterReport: isEndedPush || tombstonedIncoming, endImmediately: isPhantomEndedReport, completion: completion ) @@ -810,6 +987,8 @@ import flutter_foreground_task callKitId: String, callKitIdSourceName: String, conversationUuid: String, + serverCallId: String? = nil, + reason: String? = nil, endAfterReport: Bool, endImmediately: Bool, completion: @escaping () -> Void @@ -831,7 +1010,9 @@ import flutter_foreground_task DispatchQueue.main.async { self.endCallsForEndedPush( reportedId: callKitId, - conversationUuid: conversationUuid + conversationUuid: conversationUuid, + serverCallId: serverCallId, + reason: reason ) } } @@ -843,6 +1024,8 @@ import flutter_foreground_task data: [ "callKitId": callKitId, "callKitIdSource": callKitIdSourceName, + "call_id": serverCallId ?? "", + "reason": reason ?? "", "conversationUuid": conversationUuid, "activeCallIds": activeIds, "activeCallsCount": activeCalls.count, @@ -910,24 +1093,34 @@ import flutter_foreground_task func onDecline(_ call: Call, _ action: CXEndCallAction) { resumeAudioAfterCallKitEnds = false audioRestartGeneration += 1 + callKitOwnsAudioSession = false action.fulfill() } func onEnd(_ call: Call, _ action: CXEndCallAction) { + // The in-app call is already leaving, or the user hung up from CallKit. + // Do not restart the audio unit after this deactivation. + resumeAudioAfterCallKitEnds = false + audioRestartGeneration += 1 + callKitOwnsAudioSession = false action.fulfill() } func onTimeOut(_ call: Call) {} func didActivateAudioSession(_ audioSession: AVAudioSession) { + callKitOwnsAudioSession = true let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true rtcSession.audioSessionDidActivate(audioSession) applyCallAudioConfiguration(active: false) - rtcSession.isAudioEnabled = true + // Do not start the audio unit here. Incoming accept has no LiveKit + // microphone track yet; enabling the unit attaches capture to nothing, + // and a later `isAudioEnabled = true` is then a no-op. } func didDeactivateAudioSession(_ audioSession: AVAudioSession) { + callKitOwnsAudioSession = false let rtcSession = RTCAudioSession.sharedInstance() rtcSession.audioSessionDidDeactivate(audioSession) // The audio unit is stopped, but isAudioEnabled stays true. A later @@ -943,6 +1136,7 @@ import flutter_foreground_task func providerDidReset() { resumeAudioAfterCallKitEnds = false audioRestartGeneration += 1 + callKitOwnsAudioSession = false } private func configureWebRTCAudioSession() { @@ -957,7 +1151,9 @@ import flutter_foreground_task private func activateInAppCallAudioSession() { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true - applyCallAudioConfiguration(active: true) + // CallKit already activated the session on answer. A second setActive + // fails or replaces that session, and the incoming microphone stays dead. + applyCallAudioConfiguration(active: !callKitOwnsAudioSession) // Starting the audio unit while the configuration lock is held deadlocks // the capture thread, so the mic stays silent. rtcSession.isAudioEnabled = true @@ -993,6 +1189,10 @@ import flutter_foreground_task audioRestartGeneration += 1 let rtcSession = RTCAudioSession.sharedInstance() rtcSession.isAudioEnabled = false + // Hang-up stops the unit first, then Dart closes CallKit. A second + // setActive(false) while CallKit still owns the session fails or + // tears the graph down in the wrong order. + if callKitOwnsAudioSession { return } rtcSession.lockForConfiguration() defer { rtcSession.unlockForConfiguration() } do { diff --git a/lib/features/menu/presentation/pages/menu_screen.dart b/lib/features/menu/presentation/pages/menu_screen.dart index 67017dd0..84d78dbb 100644 --- a/lib/features/menu/presentation/pages/menu_screen.dart +++ b/lib/features/menu/presentation/pages/menu_screen.dart @@ -162,17 +162,18 @@ class MenuScreen extends StatelessWidget { actions: [ CupertinoDialogAction( onPressed: () async { + try { + // Logout revokes the Sanctum token. Unregister first, or + // FCM / VoIP tokens stay on the server and calls still ring. + await sl().unregisterPushTokens(); + } catch (e, s) { + await sl().report(e, s, origin: 'MenuScreen'); + } try { await sl().post('${sl().baseUrl}/api/profile/logout'); } catch (e, s) { await sl().report(e, s, origin: 'MenuScreen'); } finally { - try { - // Needs Authorization header → must run before removing token. - await sl().unregisterPushTokens(); - } catch (e, s) { - await sl().report(e, s, origin: 'MenuScreen'); - } await sl().endCall(); await sl().clear(); await TokenStorage.removeTokenInStorage(); diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 197c2668..d0be4c59 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/features/video_calls/services/video_call_foreground_c import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.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/presentation/widgets/video_call_top_overlay.dart'; @@ -18,6 +19,7 @@ import 'package:eatvio_app/features/video_calls/repositories/video_call_reposito import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:livekit_client/livekit_client.dart' as lk; import 'package:permission_handler/permission_handler.dart'; @@ -454,14 +456,12 @@ class VideoCallManager { await _disconnectLiveKitOnly(); if (shouldNotifyBackend) { try { - await _videoCallRepository.notifyCallEnded( - uuid, - callId: _activeCallId, - ); + await _videoCallRepository.notifyCallEnded(uuid, callId: _activeCallId); } catch (_) {} } await _foreground.stopOngoingCall(); await _stopCallAudioSession(); + await _dismissIosCallKitAfterHangup(uuid); _lifecycleBinding.detach(); _callStageEntered = false; _videoPausedForBackground = false; @@ -552,6 +552,7 @@ class VideoCallManager { await _disconnectLiveKitOnly(); await _foreground.stopOngoingCall(); await _stopCallAudioSession(); + await _dismissIosCallKitAfterHangup(uuid); _lifecycleBinding.detach(); _callStageEntered = false; _videoPausedForBackground = false; @@ -1310,4 +1311,27 @@ class VideoCallManager { await _audioSession.stop(); } catch (_) {} } + + /// Incoming iOS keeps CallKit up for the whole call so the microphone + /// session stays. Hang-up has to close that CallKit call. + Future _dismissIosCallKitAfterHangup(String? conversationUuid) async { + if (!Platform.isIOS) 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; + if (!matches) continue; + VideoCallRemoteEndRegistry.mark(call.id); + try { + await FlutterCallkitIncoming.endCall(call.id); + } catch (_) {} + } + } 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 41601efb..937318f9 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -11,6 +11,7 @@ import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler. import 'package:eatvio_app/features/video_calls/services/video_call_peer_departure.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_remote_end_registry.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_tombstone_registry.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; @@ -77,6 +78,8 @@ class VideoCallCallKitCoordinator { _scheduleNativeVoipDiagnosticsDrain(); case 'acceptedVoipCall': await _handleNativeAcceptedVoipCall(call.arguments); + case 'tombstoneCallId': + _handleNativeTombstoneCallId(call.arguments); } }); await _firebaseApi.registerPushToken(); @@ -517,6 +520,17 @@ class VideoCallCallKitCoordinator { ); } + void _handleNativeTombstoneCallId(Object? arguments) { + final data = _stringKeyedMap(arguments); + final callId = _parseString(data, const ['id', 'call_id', 'callId']); + if (callId == null || callId.isEmpty) return; + VideoCallTombstoneRegistry.mark(callId); + log( + 'VideoCallCallKitCoordinator: native tombstone call_id=$callId', + name: 'VideoCallCallKit', + ); + } + Future _handleNativeAcceptedVoipCall(Object? arguments) async { if (!Platform.isIOS) return; @@ -543,6 +557,9 @@ class VideoCallCallKitCoordinator { return; } + final callId = _parseCallId(extra) ?? callKitId; + _manager.rememberCallId(callId); + await _deliverIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, @@ -700,18 +717,15 @@ class VideoCallCallKitCoordinator { }) async { if (_acceptNavigationCompletedIds.contains(callKitId)) return; - _suppressedEndCallKitIds.add(callKitId); final params = _paramsForCallId(callKitId); - final callId = _parseCallId(params?.extra ?? const {}) ?? + final callId = + _parseCallId(params?.extra ?? const {}) ?? (callKitId.contains('-') ? callKitId : null); if (callId != null) { _manager.rememberCallId(callId); } unawaited( - _manager.notifyIncomingCallAccepted( - conversationUuid, - callId: callId, - ), + _manager.notifyIncomingCallAccepted(conversationUuid, callId: callId), ); try { @@ -751,7 +765,7 @@ class VideoCallCallKitCoordinator { unawaited(_drainPendingIncomingAcceptWithRetries()); } - await FlutterCallkitIncoming.endCall(callKitId); + await _finishCallKitAfterAccept(callKitId); } catch (error, stackTrace) { log( 'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error', @@ -759,12 +773,17 @@ class VideoCallCallKitCoordinator { stackTrace: stackTrace, ); } finally { - unawaited( - Future.delayed(const Duration(seconds: 2), () { - _suppressedEndCallKitIds.remove(callKitId); - _acceptNavigationCompletedIds.remove(callKitId); - }), - ); + // Android ends CallKit right after accept, so the plugin ENDED event + // 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) { + unawaited( + Future.delayed(const Duration(seconds: 2), () { + _suppressedEndCallKitIds.remove(callKitId); + _acceptNavigationCompletedIds.remove(callKitId); + }), + ); + } } } @@ -819,10 +838,24 @@ class VideoCallCallKitCoordinator { } catch (_) {} } try { - await FlutterCallkitIncoming.endCall(pending.callKitId); + await _finishCallKitAfterAccept(pending.callKitId); } catch (_) {} } + /// After answer: Android dismisses the incoming notification. iOS keeps the + /// CallKit call so the audio session stays, and marks it connected so the + /// plugin stops treating every later `endCall` as the last PushKit report. + Future _finishCallKitAfterAccept(String callKitId) async { + if (Platform.isIOS) { + try { + await FlutterCallkitIncoming.setCallConnected(callKitId); + } catch (_) {} + return; + } + _suppressedEndCallKitIds.add(callKitId); + await FlutterCallkitIncoming.endCall(callKitId); + } + Future _settleUiAfterResume() async { final binding = WidgetsBinding.instance; for (var frame = 0; frame < 3; frame++) { @@ -885,8 +918,8 @@ class VideoCallCallKitCoordinator { extra, handle: params?.handle ?? callKitId, ); - final callId = _parseCallId(extra) ?? - (callKitId.contains('-') ? callKitId : null); + final callId = + _parseCallId(extra) ?? (callKitId.contains('-') ? callKitId : null); final remoteEndDismissal = callKitDeclineIsEndedPush( cachedExtra: params?.extra, @@ -921,13 +954,11 @@ class VideoCallCallKitCoordinator { if (_manager.isActiveCall || _cubit.state.connectPhase != VideoCallConnectPhase.idle) { await _cubit.endCall(); + VideoCallNotificationNavigator.closeCallRouteIfOpen(); return; } - await _manager.declineIncomingCall( - conversationUuid, - callId: callId, - ); + await _manager.declineIncomingCall(conversationUuid, callId: callId); if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) { await _cubit.onCallEndedExternally(); } @@ -948,19 +979,35 @@ class VideoCallCallKitCoordinator { // iOS reports an ended VoIP push to CallKit and closes it right away. // That close is marked and must be remembered before the decline (or the // later timeout) event for the same id arrives with no marker. - final remoteEnd = callKitDeclineIsEndedPush( - cachedExtra: _paramsForCallId(callKitId)?.extra, - eventExtra: eventExtra, - ); + final remoteEnd = + callKitDeclineIsEndedPush( + cachedExtra: _paramsForCallId(callKitId)?.extra, + eventExtra: eventExtra, + ) || + VideoCallRemoteEndRegistry.contains(callKitId); if (remoteEnd) { VideoCallRemoteEndRegistry.mark(callKitId); } _forgetCallKitId(callKitId); - // Native already ends a remote-ended call. Calling endCall again here - // overwrites the plugin's stored payload, so the decline that follows - // would lose the marker. + final inAppCall = + _manager.isActiveCall || + _cubit.state.connectPhase != VideoCallConnectPhase.idle; + + if (inAppCall) { + if (remoteEnd) { + await _cubit.handleRemoteCallEnded(); + } else { + await _cubit.endCall(); + VideoCallNotificationNavigator.closeCallRouteIfOpen(); + } + return; + } + + // Native already ends a remote-ended ringing call. Calling endCall + // again here overwrites the plugin's stored payload, so the decline + // that follows would lose the marker. if (remoteEnd) return; try { @@ -970,6 +1017,7 @@ class VideoCallCallKitCoordinator { void _forgetCallKitId(String callKitId) { _callKitParamsById.remove(callKitId); + _acceptNavigationCompletedIds.remove(callKitId); if (_activeCallKitId == callKitId) { _activeCallKitId = null; } 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 537b05d3..e0c67b71 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 @@ -63,6 +63,19 @@ abstract final class VideoCallEndedPushHandler { ); } + /// Closes the CallKit / incoming UI without ending the LiveKit session. + /// Used when this client hangs up: CallKit stays up for the whole incoming + /// call on iOS and must be dismissed with the in-app hang-up. + static Future dismissCallKitOnly({ + String? conversationUuid, + String? callId, + }) { + return _dismissViaCallKit( + conversationUuid: conversationUuid, + callId: callId, + ); + } + static Future endActiveCallIfMatches({ String? conversationUuid, String? callId, diff --git a/pubspec.yaml b/pubspec.yaml index 25c1942b..353276e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.60+149 +version: 1.3.61+150 publish_to: 'none' environment: 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 2ec05e08..9bac508a 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 @@ -167,5 +167,18 @@ void main() { expect(VideoCallRemoteEndRegistry.contains('other-id'), isTrue); }, ); + + test('dismissCallKitOnly ends the matching CallKit call', () async { + await VideoCallEndedPushHandler.dismissCallKitOnly( + conversationUuid: conversation, + ); + + final ended = calls + .where((call) => call.method == 'endCall') + .map((call) => (call.arguments as Map)['id']) + .toList(); + expect(ended, ['ringing-id']); + expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue); + }); }); }