From 6d2935400a2e86d69a19cfc43cb385ec309e56c9 Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 16 Oct 2026 13:49:48 +0300 Subject: [PATCH] feat(video_calls): ios voip push, mix and crushes fix --- ios/Runner/AppDelegate.swift | 435 +++++++++++++++--- .../bloc/chats_cubit/chats_cubit.dart | 16 +- .../video_call_ended_push_handler.dart | 36 +- .../video_call_incoming_push_presenter.dart | 86 +++- pubspec.yaml | 2 +- .../presentation/bloc/chats_cubit_test.dart | 33 ++ .../video_call_ended_push_handler_test.dart | 46 ++ ...deo_call_incoming_push_presenter_test.dart | 150 ++++++ 8 files changed, 715 insertions(+), 89 deletions(-) create mode 100644 test/features/video_calls/services/video_call_ended_push_handler_test.dart create mode 100644 test/features/video_calls/services/video_call_incoming_push_presenter_test.dart diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 5355c97b..4608f102 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -21,6 +21,24 @@ import flutter_foreground_task /// 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. 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 + /// in-app path can see it and not report the same call again. + private var reservedIncomingCallIds: [String: Date] = [:] + private let incomingCallClaimLock = NSLock() + private let incomingCallClaimTtl: TimeInterval = 90 + private let voipPushQueue = DispatchQueue(label: "com.eatvioapp.voip") + private var voipCallReporterStorage: VoipCallReporter? + private var audioRestartGeneration = 0 + + private var voipCallReporter: VoipCallReporter { + if let voipCallReporterStorage { + return voipCallReporterStorage + } + let reporter = VoipCallReporter() + voipCallReporterStorage = reporter + return reporter + } override func application( _ application: UIApplication, @@ -31,10 +49,12 @@ import flutter_foreground_task GeneratedPluginRegistrant.register(with: registry) } - let mainQueue = DispatchQueue.main - voipRegistry = PKPushRegistry(queue: mainQueue) + // Not the main queue. Flutter startup blocks main, and PushKit aborts the + // process if this push is not reported before that returns. + voipRegistry = PKPushRegistry(queue: voipPushQueue) voipRegistry?.delegate = self voipRegistry?.desiredPushTypes = [PKPushType.voIP] + configureWebRTCAudioSession() if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self @@ -73,6 +93,10 @@ import flutter_foreground_task result(self.drainVoipDiagnostics()) case "drainPendingAcceptedVoipCall": result(self.drainPendingAcceptedVoipCall()) + case "isIncomingCallClaimed": + result(self.isIncomingCallClaimed(self.incomingCallId(from: call.arguments))) + case "reserveIncomingCall": + result(self.reserveIncomingCall(self.incomingCallId(from: call.arguments))) default: result(FlutterMethodNotImplemented) } @@ -373,7 +397,9 @@ import flutter_foreground_task events = Array(events.suffix(20)) } defaults.set(events, forKey: voipDiagnosticsStorageKey) - voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil) + DispatchQueue.main.async { [weak self] in + self?.voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil) + } } private func drainVoipDiagnostics() -> [[String: Any]] { @@ -383,6 +409,160 @@ import flutter_foreground_task return events } + private func isEndedVideoCallMarker(_ marker: String) -> Bool { + if marker == "video_call_ended" || marker == "video_call_end" { + return true + } + return marker.contains("video_call") + && (marker.contains("ended") || marker.hasSuffix("_end") || marker.hasSuffix("_cancel")) + } + + private func incomingCallId(from arguments: Any?) -> String { + let raw: String? + if let id = arguments as? String { + raw = id + } else if let map = arguments as? [String: Any] { + raw = map["id"] as? String + } else { + raw = nil + } + return raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + } + + /// Caller must hold `incomingCallClaimLock`. + private func pruneIncomingCallClaims(now: Date = Date()) { + reservedIncomingCallIds = reservedIncomingCallIds.filter { + now.timeIntervalSince($0.value) < incomingCallClaimTtl + } + } + + private func activeIncomingCallIds() -> Set { + let calls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? [] + return Set(calls.compactMap { ($0["id"] as? String)?.lowercased() }) + } + + private func isIncomingCallClaimed(_ id: String) -> Bool { + let normalized = id.lowercased() + guard !normalized.isEmpty else { return false } + let reserved = incomingCallClaimLock.withLock { () -> Bool in + pruneIncomingCallClaims() + return reservedIncomingCallIds[normalized] != nil + } + if reserved { + return true + } + return activeIncomingCallIds().contains(normalized) + } + + /// Returns false when this id is already reserved. The VoIP push still + /// reports the call; this only stops the in-app path from reporting it too. + private func reserveIncomingCall(_ id: String) -> Bool { + let normalized = id.lowercased() + guard !normalized.isEmpty else { return false } + return incomingCallClaimLock.withLock { + pruneIncomingCallClaims() + if reservedIncomingCallIds[normalized] != nil { + return false + } + reservedIncomingCallIds[normalized] = Date() + return true + } + } + + private func releaseIncomingCallClaim(_ id: String) { + incomingCallClaimLock.withLock { + _ = reservedIncomingCallIds.removeValue(forKey: id.lowercased()) + } + } + + /// 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) { + guard let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else { + releaseIncomingCallClaim(reportedId) + return + } + let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) + var ids: [String] = [] + func append(_ id: String) { + let normalized = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalized.isEmpty else { return } + if ids.contains(where: { $0.caseInsensitiveCompare(normalized) == .orderedSame }) { + return + } + ids.append(normalized) + } + append(reportedId) + for call in plugin.activeCalls() { + let callId = (call["id"] as? String) ?? "" + let handle = (call["handle"] as? String) ?? "" + let extraUuid = extraConversationUuid(call["extra"]) + let sameId = callId.caseInsensitiveCompare(reportedId) == .orderedSame + let sameConversation = !conversation.isEmpty && ( + handle.caseInsensitiveCompare(conversation) == .orderedSame + || extraUuid.caseInsensitiveCompare(conversation) == .orderedSame + ) + if sameId || sameConversation { + append(callId) + } + } + for id in ids { + let endData = flutter_callkit_incoming.Data( + id: id, + nameCaller: "Итвио", + handle: conversation, + type: 1 + ) + plugin.endCall(endData) + releaseIncomingCallClaim(id) + } + } + + private func activeCallId(forConversation conversationUuid: String) -> String? { + let conversation = conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) + guard !conversation.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 handle = (call["handle"] as? String) ?? "" + let extraUuid = extraConversationUuid(call["extra"]) + if handle.caseInsensitiveCompare(conversation) == .orderedSame + || extraUuid.caseInsensitiveCompare(conversation) == .orderedSame + { + return callId + } + } + return nil + } + + private func extraConversationUuid(_ extra: Any?) -> String { + let map: [String: Any] + if let typed = extra as? [String: Any] { + map = typed + } else 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 "" + } + for key in ["conversationUuid", "conversation_uuid"] { + if let value = map[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + return "" + } func pushRegistry( _ registry: PKPushRegistry, @@ -390,11 +570,15 @@ import flutter_foreground_task for type: PKPushType ) { let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined() - SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken) + DispatchQueue.main.async { + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP(deviceToken) + } } func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) { - SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("") + DispatchQueue.main.async { + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("") + } } func pushRegistry( @@ -419,8 +603,8 @@ import flutter_foreground_task ] .compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") } - if pushMarkers.contains(where: { $0.contains("video_call") }) - && pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) { + let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) }) + if isEndedPush { recordVoipDiagnostic( "voip_end_or_cancel_received", data: [ @@ -428,9 +612,6 @@ import flutter_foreground_task "payload": dict, ] ) - SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls() - completion() - return } let rawNameCaller = (dict["nameCaller"] as? String) @@ -455,14 +636,20 @@ import flutter_foreground_task let parsedMessageId = voipMessageId(dict["message_id"]) let id: String let callKitIdSourceName: String - if let derived = derivedCallKitId( + if isEndedPush, let existing = activeCallId(forConversation: trimmedConversationUuid) { + 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 + id = UUID().uuidString.lowercased() callKitIdSourceName = "generated" } let isVideo = (dict["isVideo"] as? Bool) @@ -508,6 +695,16 @@ import flutter_foreground_task ) data.duration = 45000 data.extra = extra as NSDictionary + // The plugin activates AVAudioSession itself on answer and again 1.2s + // later. That drops the ringing UI and leaves one side silent. CallKit + // and WebRTC own the session instead. + data.configureAudioSession = false + data.audioSessionActive = false + data.audioSessionMode = "videoChat" + data.ringtonePath = "system_ringtone_default" + // Remember the id so the in-app socket path does not report it again. + // The push itself must still be reported, or iOS kills the process. + _ = reserveIncomingCall(id) NSLog( "[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)" @@ -525,53 +722,89 @@ import flutter_foreground_task ] ) - guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else { - NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)") - recordVoipDiagnostic( - "callkit_plugin_missing", - data: [ - "callKitId": id, - "conversationUuid": conversationUuid, - ] - ) - completion() - return + reportVoipPushToCallKit( + data, + callKitId: id, + callKitIdSourceName: callKitIdSourceName, + conversationUuid: conversationUuid, + endAfterReport: isEndedPush, + completion: completion + ) + } + + /// PushKit kills the app unless every VoIP push is reported to CallKit and + /// its completion handler runs. Both happen here, including an "ended" push. + private func reportVoipPushToCallKit( + _ data: flutter_callkit_incoming.Data, + callKitId: String, + callKitIdSourceName: String, + conversationUuid: String, + endAfterReport: Bool, + completion: @escaping () -> Void + ) { + let gate = VoipPushCompletion(completion) + let finish = { self.voipPushQueue.async { gate.call() } } + voipPushQueue.asyncAfter(deadline: .now() + 1.5) { + finish() } - callkitPlugin.showCallkitIncoming( - data, - fromPushKit: true - ) { - let activeCalls = callkitPlugin.activeCalls() - let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",") - NSLog( - "[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)" - ) - self.recordVoipDiagnostic( - "callkit_report_completed", - data: [ - "callKitId": id, - "callKitIdSource": callKitIdSourceName, - "conversationUuid": conversationUuid, - "activeCallIds": activeIds, - "activeCallsCount": activeCalls.count, - ] - ) - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - let delayedActiveCalls = callkitPlugin.activeCalls() - let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",") + let afterReport = { + if endAfterReport { + DispatchQueue.main.async { + self.endCallsForEndedPush( + reportedId: callKitId, + conversationUuid: conversationUuid + ) + } + } + if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance { + let activeCalls = plugin.activeCalls() + let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",") self.recordVoipDiagnostic( - "callkit_active_calls_delayed", + "callkit_report_completed", data: [ - "callKitId": id, + "callKitId": callKitId, "callKitIdSource": callKitIdSourceName, "conversationUuid": conversationUuid, - "activeCallIds": delayedActiveIds, - "activeCallsCount": delayedActiveCalls.count, + "activeCallIds": activeIds, + "activeCallsCount": activeCalls.count, + "endAfterReport": endAfterReport, ] ) } - completion() + finish() + } + + if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance { + plugin.showCallkitIncoming(data, fromPushKit: true, completion: afterReport) + return + } + + NSLog("[EatvioVoIP] CallKit plugin missing id=\(callKitId)") + recordVoipDiagnostic( + "callkit_plugin_missing", + data: [ + "callKitId": callKitId, + "conversationUuid": conversationUuid, + ] + ) + guard let uuid = UUID(uuidString: data.uuid) else { + finish() + return + } + voipCallReporter.report( + uuid: uuid, + callerName: data.nameCaller, + hasVideo: data.type > 0 + ) { _ in + if endAfterReport { + self.voipCallReporter.provider.reportCall( + with: uuid, + endedAt: Date(), + reason: .remoteEnded + ) + } + afterReport() } } @@ -598,6 +831,7 @@ import flutter_foreground_task func onDecline(_ call: Call, _ action: CXEndCallAction) { resumeAudioAfterCallKitEnds = false + audioRestartGeneration += 1 action.fulfill() } @@ -611,6 +845,7 @@ import flutter_foreground_task let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true rtcSession.audioSessionDidActivate(audioSession) + applyCallAudioConfiguration(active: false) rtcSession.isAudioEnabled = true } @@ -629,46 +864,55 @@ import flutter_foreground_task func providerDidReset() { resumeAudioAfterCallKitEnds = false + audioRestartGeneration += 1 + } + + private func configureWebRTCAudioSession() { + let configuration = callAudioConfiguration() + RTCAudioSessionConfiguration.setWebRTC(configuration) } private func prepareCallAudioCategory() { - let rtcSession = RTCAudioSession.sharedInstance() - rtcSession.lockForConfiguration() - defer { rtcSession.unlockForConfiguration() } - let configuration = callAudioConfiguration() - do { - try rtcSession.setConfiguration(configuration) - } catch { - NSLog("[EatvioVoIP] prepare call audio category failed: \(error)") - } + applyCallAudioConfiguration(active: false) } private func activateInAppCallAudioSession() { let rtcSession = RTCAudioSession.sharedInstance() rtcSession.useManualAudio = true - rtcSession.lockForConfiguration() - do { - try rtcSession.setConfiguration(callAudioConfiguration(), active: true) - } catch { - NSLog("[EatvioVoIP] activate call audio session failed: \(error)") - } - rtcSession.unlockForConfiguration() + applyCallAudioConfiguration(active: true) // Starting the audio unit while the configuration lock is held deadlocks // the capture thread, so the mic stays silent. rtcSession.isAudioEnabled = true } - /// Cycles the audio unit after the microphone track exists. Enabling audio - /// before the track is created leaves capture attached to nothing. + /// Restarts capture after the previous audio graph has disconnected. + /// Toggling the unit in the same turn makes voice processing throw. private func restartInAppCallAudioUnit() { - activateInAppCallAudioSession() + audioRestartGeneration += 1 + let generation = audioRestartGeneration let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.useManualAudio = true rtcSession.isAudioEnabled = false - rtcSession.isAudioEnabled = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + guard let self, generation == self.audioRestartGeneration else { return } + self.activateInAppCallAudioSession() + } + } + + private func applyCallAudioConfiguration(active: Bool) { + let rtcSession = RTCAudioSession.sharedInstance() + rtcSession.lockForConfiguration() + defer { rtcSession.unlockForConfiguration() } + do { + try rtcSession.setConfiguration(callAudioConfiguration(), active: active) + } catch { + NSLog("[EatvioVoIP] apply call audio configuration failed: \(error)") + } } private func deactivateInAppCallAudioSession() { resumeAudioAfterCallKitEnds = false + audioRestartGeneration += 1 let rtcSession = RTCAudioSession.sharedInstance() rtcSession.isAudioEnabled = false rtcSession.lockForConfiguration() @@ -691,6 +935,57 @@ import flutter_foreground_task // the input on the receiver, so the mic is silent once audio goes to // the loudspeaker. configuration.mode = AVAudioSession.Mode.videoChat.rawValue + // Voice processing rejects 44.1 kHz and throws from AVAudioEngine. + configuration.sampleRate = 48000 + configuration.ioBufferDuration = 0.02 + configuration.inputNumberOfChannels = 1 + configuration.outputNumberOfChannels = 1 return configuration } } + +/// Reports a VoIP push when the Flutter CallKit plugin is not registered yet. +private final class VoipPushCompletion { + private var finished = false + private let completion: () -> Void + + init(_ completion: @escaping () -> Void) { + self.completion = completion + } + + func call() { + if finished { return } + finished = true + completion() + } +} + +private final class VoipCallReporter: NSObject, CXProviderDelegate { + let provider: CXProvider + + override init() { + let configuration = CXProviderConfiguration(localizedName: "Итвио") + configuration.supportsVideo = true + configuration.maximumCallGroups = 1 + configuration.maximumCallsPerCallGroup = 1 + configuration.supportedHandleTypes = [.generic] + provider = CXProvider(configuration: configuration) + super.init() + provider.setDelegate(self, queue: nil) + } + + func report( + uuid: UUID, + callerName: String, + hasVideo: Bool, + completion: @escaping (Error?) -> Void + ) { + let update = CXCallUpdate() + update.remoteHandle = CXHandle(type: .generic, value: callerName) + update.localizedCallerName = callerName + update.hasVideo = hasVideo + provider.reportNewIncomingCall(with: uuid, update: update, completion: completion) + } + + func providerDidReset(_ provider: CXProvider) {} +} 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 4cd6c9ce..f9f72353 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -27,7 +27,7 @@ class ChatsCubit extends Cubit { /// Test seam. Production uses CallKit. Future Function(VideoCallPushPayload payload)? presentIncomingVideoCall; - Future Function()? dismissIncomingVideoCall; + Future Function(String? conversationUuid)? dismissIncomingVideoCall; ChatsCubit({ required this.chatRepository, @@ -230,7 +230,7 @@ class ChatsCubit extends Cubit { }) { final kind = VideoCallChatSignal.kindOf(message); if (kind == VideoCallChatSignalKind.ended) { - unawaited(_dismissIncomingVideoCallUi()); + unawaited(_dismissIncomingVideoCallUi(message.roomId)); unawaited( VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId), ); @@ -260,10 +260,16 @@ class ChatsCubit extends Cubit { } } - Future _dismissIncomingVideoCallUi() async { + Future _dismissIncomingVideoCallUi(String? conversationUuid) async { try { - await (dismissIncomingVideoCall ?? - VideoCallEndedPushHandler.dismissIncomingCallUi)(); + final dismiss = dismissIncomingVideoCall; + if (dismiss != null) { + await dismiss(conversationUuid); + return; + } + await VideoCallEndedPushHandler.dismissIncomingCallUi( + conversationUuid: conversationUuid, + ); } catch (e, st) { log( 'ChatsCubit: failed to dismiss incoming video call: $e', 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 f7ed9f98..5da0b1ca 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 @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; /// Dismisses incoming call UI after a remote `video_call_ended` push. @@ -11,9 +12,7 @@ abstract final class VideoCallEndedPushHandler { 'com.eatvioapp/video_call_push', ); - static Future dismissIncomingCallUi({ - String? conversationUuid, - }) async { + static Future dismissIncomingCallUi({String? conversationUuid}) async { await endActiveCallIfMatches(conversationUuid); if (Platform.isAndroid) { try { @@ -22,7 +21,7 @@ abstract final class VideoCallEndedPushHandler { } catch (_) {} } - await _dismissViaCallKit(); + await _dismissViaCallKit(conversationUuid); } static Future endActiveCallIfMatches(String? conversationUuid) async { @@ -37,18 +36,37 @@ abstract final class VideoCallEndedPushHandler { } catch (_) {} } - static Future _dismissViaCallKit() async { + static bool callMatchesConversation( + CallKitParams call, + String conversationUuid, + ) { + final uuid = conversationUuid.trim().toLowerCase(); + if (uuid.isEmpty) return false; + final handle = call.handle?.trim().toLowerCase(); + if (handle != null && handle == uuid) return true; + final extra = call.extra; + if (extra == null) return false; + for (final key in const ['conversationUuid', 'conversation_uuid']) { + final value = extra[key]; + if (value is String && value.trim().toLowerCase() == uuid) return true; + } + return false; + } + + static Future _dismissViaCallKit(String? conversationUuid) async { + final uuid = conversationUuid?.trim(); try { final activeCalls = await FlutterCallkitIncoming.activeCalls(); + if (uuid == null || uuid.isEmpty) { + await FlutterCallkitIncoming.endAllCalls(); + return; + } for (final call in activeCalls) { + if (!callMatchesConversation(call, uuid)) continue; try { await FlutterCallkitIncoming.endCall(call.id); } catch (_) {} } } catch (_) {} - - try { - await FlutterCallkitIncoming.endAllCalls(); - } catch (_) {} } } diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index 09fa7ab4..de029cc8 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -2,18 +2,48 @@ import 'dart:io'; import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_callkit_incoming/entities/entities.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +typedef VideoCallIncomingClaim = Future Function(String callId); +typedef VideoCallIncomingShow = Future Function(CallKitParams params); +typedef VideoCallIncomingDelay = Future Function(Duration delay); + +/// Shows the system incoming-call UI. +/// +/// On iOS the VoIP push reports the call even when the app is in the +/// foreground. A second report for the same id dismisses that UI, so the +/// socket path waits for the push and presents CallKit only if the push +/// never arrived. abstract final class VideoCallIncomingPushPresenter { static const String _androidAccentColor = '#2787F5'; + static const MethodChannel _voipChannel = MethodChannel( + 'com.eatvioapp/voip_diagnostics', + ); - static Future showIncomingCall(VideoCallPushPayload payload) async { + /// Absolute delays from the start of an iOS present attempt. + static const List iosClaimPollDelays = [ + Duration.zero, + Duration(milliseconds: 300), + Duration(milliseconds: 800), + Duration(milliseconds: 1500), + ]; + + static Future showIncomingCall( + VideoCallPushPayload payload, { + bool? isIos, + VideoCallIncomingClaim? isIncomingCallClaimed, + VideoCallIncomingClaim? reserveIncomingCall, + VideoCallIncomingShow? showCallkitIncoming, + VideoCallIncomingDelay? delay, + List? claimPollDelays, + }) async { final callerName = payload.counterpartName?.trim().isNotEmpty == true ? payload.counterpartName!.trim() : 'Итвио'; - - final callId = Platform.isIOS + final ios = isIos ?? Platform.isIOS; + final callId = ios ? VideoCallKitId.iosUuid( conversationUuid: payload.conversationUuid, messageId: payload.messageId, @@ -21,6 +51,22 @@ abstract final class VideoCallIncomingPushPresenter { payload.conversationUuid : payload.conversationUuid; + if (ios) { + final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed; + final reserve = reserveIncomingCall ?? _reserveIncomingCall; + final wait = delay ?? Future.delayed; + final polls = claimPollDelays ?? iosClaimPollDelays; + final started = DateTime.now(); + for (final checkpoint in polls) { + final remaining = checkpoint - DateTime.now().difference(started); + if (remaining > Duration.zero) { + await wait(remaining); + } + if (await _claimOrFalse(claimed, callId)) return; + } + if (!await _claimOrFalse(reserve, callId)) return; + } + final params = CallKitParams( id: callId, nameCaller: callerName, @@ -57,10 +103,42 @@ abstract final class VideoCallIncomingPushPresenter { ios: const IOSParams( handleType: 'generic', supportsVideo: true, + configureAudioSession: false, + audioSessionActive: false, + audioSessionMode: 'videoChat', ringtonePath: 'system_ringtone_default', ), ); - await FlutterCallkitIncoming.showCallkitIncoming(params); + await (showCallkitIncoming ?? FlutterCallkitIncoming.showCallkitIncoming)( + params, + ); + } + + static Future _claimOrFalse( + VideoCallIncomingClaim claim, + String callId, + ) async { + try { + return await claim(callId); + } catch (_) { + return false; + } + } + + static Future _isIncomingCallClaimed(String callId) async { + final claimed = await _voipChannel.invokeMethod( + 'isIncomingCallClaimed', + {'id': callId}, + ); + return claimed ?? false; + } + + static Future _reserveIncomingCall(String callId) async { + final reserved = await _voipChannel.invokeMethod( + 'reserveIncomingCall', + {'id': callId}, + ); + return reserved ?? false; } } diff --git a/pubspec.yaml b/pubspec.yaml index 99132ffa..0b2adf7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.53+142 +version: 1.3.54+143 publish_to: 'none' environment: diff --git a/test/features/chat/presentation/bloc/chats_cubit_test.dart b/test/features/chat/presentation/bloc/chats_cubit_test.dart index 030fa7db..755164eb 100644 --- a/test/features/chat/presentation/bloc/chats_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chats_cubit_test.dart @@ -479,6 +479,39 @@ void main() { }, ); + test( + '_handleNewMessageViaSocket - завершение видеозвонка закрывает только этот чат', + () async { + const chatId = 'chat-id'; + final dismissed = []; + chatsCubit.dismissIncomingVideoCall = (uuid) async { + dismissed.add(uuid); + }; + chatsCubit.emit( + ChatsState( + state: BaseState.success, + chatRooms: [buildChatRoom(chatId)], + ), + ); + when(mockWsManager.userId).thenReturn(42); + + final controller = await connectToSocketStream(); + controller.add( + socketEvent( + id: 502, + authorId: 7, + roomId: chatId, + data: 'Анна завершил(а) видеозвонок.', + ), + ); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(dismissed, [chatId]); + + await controller.close(); + }, + ); + test('close - закрывает cubit и отписывается от подписок', () async { final mockSubscription1 = MockStreamSubscription(); 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 new file mode 100644 index 00000000..62bbe6a8 --- /dev/null +++ b/test/features/video_calls/services/video_call_ended_push_handler_test.dart @@ -0,0 +1,46 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart'; +import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const conversation = '11111111-1111-4111-8111-111111111111'; + + group('VideoCallEndedPushHandler.callMatchesConversation', () { + test('matches the conversation stored as the call handle', () { + const call = CallKitParams(id: 'derived-id', handle: conversation); + + expect( + VideoCallEndedPushHandler.callMatchesConversation(call, conversation), + isTrue, + ); + }); + + test('matches the conversation stored in the call payload', () { + const call = CallKitParams( + id: 'derived-id', + handle: 'Итвио', + extra: {'conversationUuid': conversation}, + ); + + expect( + VideoCallEndedPushHandler.callMatchesConversation( + call, + conversation.toUpperCase(), + ), + isTrue, + ); + }); + + test('leaves a call from another chat alone', () { + const call = CallKitParams( + id: 'other-id', + handle: '22222222-2222-4222-8222-222222222222', + ); + + expect( + VideoCallEndedPushHandler.callMatchesConversation(call, conversation), + isFalse, + ); + }); + }); +} diff --git a/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart b/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart new file mode 100644 index 00000000..29cc686a --- /dev/null +++ b/test/features/video_calls/services/video_call_incoming_push_presenter_test.dart @@ -0,0 +1,150 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_kit_id.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:flutter_callkit_incoming/entities/entities.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const payload = VideoCallPushPayload( + conversationUuid: '11111111-1111-4111-8111-111111111111', + counterpartName: ' Anna ', + messageId: 26, + ); + + final callId = VideoCallKitId.iosUuid( + conversationUuid: payload.conversationUuid, + messageId: payload.messageId, + ); + + group('VideoCallIncomingPushPresenter', () { + test( + 'iOS keeps the push-reported call and does not show it again', + () async { + final shown = []; + final delays = []; + + await VideoCallIncomingPushPresenter.showIncomingCall( + payload, + isIos: true, + isIncomingCallClaimed: (_) async => true, + reserveIncomingCall: (_) async => true, + showCallkitIncoming: (params) async { + shown.add(params); + }, + delay: (duration) async { + delays.add(duration); + }, + ); + + expect(shown, isEmpty); + expect(delays, isEmpty); + expect(callId, isNotNull); + }, + ); + + test('iOS shows the system call once the push does not claim it', () async { + final shown = []; + var polls = 0; + + await VideoCallIncomingPushPresenter.showIncomingCall( + payload, + isIos: true, + claimPollDelays: const [ + Duration.zero, + Duration(milliseconds: 300), + ], + isIncomingCallClaimed: (_) async { + polls += 1; + return false; + }, + reserveIncomingCall: (id) async { + expect(id, callId); + return true; + }, + showCallkitIncoming: (params) async { + shown.add(params); + }, + delay: (_) async {}, + ); + + expect(polls, 2); + expect(shown, hasLength(1)); + expect(shown.single.id, callId); + expect(shown.single.nameCaller, 'Anna'); + expect(shown.single.ios?.configureAudioSession, isFalse); + expect(shown.single.ios?.audioSessionActive, isFalse); + expect(shown.single.ios?.audioSessionMode, 'videoChat'); + expect(shown.single.ios?.ringtonePath, 'system_ringtone_default'); + }); + + test('iOS stops polling when the push claims the call', () async { + final shown = []; + var polls = 0; + + await VideoCallIncomingPushPresenter.showIncomingCall( + payload, + isIos: true, + claimPollDelays: const [ + Duration.zero, + Duration(milliseconds: 300), + Duration(milliseconds: 800), + ], + isIncomingCallClaimed: (_) async { + polls += 1; + return polls == 2; + }, + reserveIncomingCall: (_) async => true, + showCallkitIncoming: (params) async { + shown.add(params); + }, + delay: (_) async {}, + ); + + expect(polls, 2); + expect(shown, isEmpty); + }); + + test('iOS does not show when the call was reserved by the push', () async { + final shown = []; + + await VideoCallIncomingPushPresenter.showIncomingCall( + payload, + isIos: true, + claimPollDelays: const [Duration.zero], + isIncomingCallClaimed: (_) async => false, + reserveIncomingCall: (_) async => false, + showCallkitIncoming: (params) async { + shown.add(params); + }, + delay: (_) async {}, + ); + + expect(shown, isEmpty); + }); + + test( + 'Android shows the incoming call without waiting for a VoIP claim', + () async { + final shown = []; + var claimed = false; + + await VideoCallIncomingPushPresenter.showIncomingCall( + payload, + isIos: false, + isIncomingCallClaimed: (_) async { + claimed = true; + return true; + }, + showCallkitIncoming: (params) async { + shown.add(params); + }, + ); + + expect(claimed, isFalse); + expect(shown, hasLength(1)); + expect(shown.single.id, payload.conversationUuid); + expect(shown.single.android?.ringtonePath, 'system_ringtone_default'); + }, + ); + }); +}