Merge pull request 'feat(video_calls): ios voip push, mix and crushes fix' (#1078) from misha/eatvio_app:main into main

Reviewed-on: eatvio/eatvio_app#1078
This commit is contained in:
2026-10-16 10:57:37 +00:00
8 changed files with 715 additions and 89 deletions

View File

@@ -21,6 +21,24 @@ import flutter_foreground_task
/// CallKit is only the ringing UI. After accept the in-app call continues, /// 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. /// so the audio session must be handed back to WebRTC when CallKit deactivates.
private var resumeAudioAfterCallKitEnds = false 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( override func application(
_ application: UIApplication, _ application: UIApplication,
@@ -31,10 +49,12 @@ import flutter_foreground_task
GeneratedPluginRegistrant.register(with: registry) GeneratedPluginRegistrant.register(with: registry)
} }
let mainQueue = DispatchQueue.main // Not the main queue. Flutter startup blocks main, and PushKit aborts the
voipRegistry = PKPushRegistry(queue: mainQueue) // process if this push is not reported before that returns.
voipRegistry = PKPushRegistry(queue: voipPushQueue)
voipRegistry?.delegate = self voipRegistry?.delegate = self
voipRegistry?.desiredPushTypes = [PKPushType.voIP] voipRegistry?.desiredPushTypes = [PKPushType.voIP]
configureWebRTCAudioSession()
if #available(iOS 10.0, *) { if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().delegate = self
@@ -73,6 +93,10 @@ import flutter_foreground_task
result(self.drainVoipDiagnostics()) result(self.drainVoipDiagnostics())
case "drainPendingAcceptedVoipCall": case "drainPendingAcceptedVoipCall":
result(self.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: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }
@@ -373,7 +397,9 @@ import flutter_foreground_task
events = Array(events.suffix(20)) events = Array(events.suffix(20))
} }
defaults.set(events, forKey: voipDiagnosticsStorageKey) 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]] { private func drainVoipDiagnostics() -> [[String: Any]] {
@@ -383,6 +409,160 @@ import flutter_foreground_task
return events 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<String> {
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( func pushRegistry(
_ registry: PKPushRegistry, _ registry: PKPushRegistry,
@@ -390,11 +570,15 @@ import flutter_foreground_task
for type: PKPushType for type: PKPushType
) { ) {
let deviceToken = credentials.token.map { String(format: "%02x", $0) }.joined() 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) { func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("") DispatchQueue.main.async {
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.setDevicePushTokenVoIP("")
}
} }
func pushRegistry( func pushRegistry(
@@ -419,8 +603,8 @@ import flutter_foreground_task
] ]
.compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") } .compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") }
if pushMarkers.contains(where: { $0.contains("video_call") }) let isEndedPush = pushMarkers.contains(where: { self.isEndedVideoCallMarker($0) })
&& pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) { if isEndedPush {
recordVoipDiagnostic( recordVoipDiagnostic(
"voip_end_or_cancel_received", "voip_end_or_cancel_received",
data: [ data: [
@@ -428,9 +612,6 @@ import flutter_foreground_task
"payload": dict, "payload": dict,
] ]
) )
SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls()
completion()
return
} }
let rawNameCaller = (dict["nameCaller"] as? String) let rawNameCaller = (dict["nameCaller"] as? String)
@@ -455,14 +636,20 @@ import flutter_foreground_task
let parsedMessageId = voipMessageId(dict["message_id"]) let parsedMessageId = voipMessageId(dict["message_id"])
let id: String let id: String
let callKitIdSourceName: 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, conversationUuid: trimmedConversationUuid,
messageId: parsedMessageId messageId: parsedMessageId
) { ) {
id = derived id = derived
callKitIdSourceName = "conversation_and_message" callKitIdSourceName = "conversation_and_message"
} else if UUID(uuidString: trimmedConversationUuid) != nil {
id = trimmedConversationUuid.lowercased()
callKitIdSourceName = "conversation"
} else { } else {
id = UUID().uuidString id = UUID().uuidString.lowercased()
callKitIdSourceName = "generated" callKitIdSourceName = "generated"
} }
let isVideo = (dict["isVideo"] as? Bool) let isVideo = (dict["isVideo"] as? Bool)
@@ -508,6 +695,16 @@ import flutter_foreground_task
) )
data.duration = 45000 data.duration = 45000
data.extra = extra as NSDictionary 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( 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) 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 { reportVoipPushToCallKit(
NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)") data,
recordVoipDiagnostic( callKitId: id,
"callkit_plugin_missing", callKitIdSourceName: callKitIdSourceName,
data: [ conversationUuid: conversationUuid,
"callKitId": id, endAfterReport: isEndedPush,
"conversationUuid": conversationUuid, completion: completion
] )
) }
completion()
return /// 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( let afterReport = {
data, if endAfterReport {
fromPushKit: true DispatchQueue.main.async {
) { self.endCallsForEndedPush(
let activeCalls = callkitPlugin.activeCalls() reportedId: callKitId,
let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",") conversationUuid: conversationUuid
NSLog( )
"[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)" }
) }
self.recordVoipDiagnostic( if let plugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance {
"callkit_report_completed", let activeCalls = plugin.activeCalls()
data: [ let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",")
"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: ",")
self.recordVoipDiagnostic( self.recordVoipDiagnostic(
"callkit_active_calls_delayed", "callkit_report_completed",
data: [ data: [
"callKitId": id, "callKitId": callKitId,
"callKitIdSource": callKitIdSourceName, "callKitIdSource": callKitIdSourceName,
"conversationUuid": conversationUuid, "conversationUuid": conversationUuid,
"activeCallIds": delayedActiveIds, "activeCallIds": activeIds,
"activeCallsCount": delayedActiveCalls.count, "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) { func onDecline(_ call: Call, _ action: CXEndCallAction) {
resumeAudioAfterCallKitEnds = false resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
action.fulfill() action.fulfill()
} }
@@ -611,6 +845,7 @@ import flutter_foreground_task
let rtcSession = RTCAudioSession.sharedInstance() let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true rtcSession.useManualAudio = true
rtcSession.audioSessionDidActivate(audioSession) rtcSession.audioSessionDidActivate(audioSession)
applyCallAudioConfiguration(active: false)
rtcSession.isAudioEnabled = true rtcSession.isAudioEnabled = true
} }
@@ -629,46 +864,55 @@ import flutter_foreground_task
func providerDidReset() { func providerDidReset() {
resumeAudioAfterCallKitEnds = false resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
}
private func configureWebRTCAudioSession() {
let configuration = callAudioConfiguration()
RTCAudioSessionConfiguration.setWebRTC(configuration)
} }
private func prepareCallAudioCategory() { private func prepareCallAudioCategory() {
let rtcSession = RTCAudioSession.sharedInstance() applyCallAudioConfiguration(active: false)
rtcSession.lockForConfiguration()
defer { rtcSession.unlockForConfiguration() }
let configuration = callAudioConfiguration()
do {
try rtcSession.setConfiguration(configuration)
} catch {
NSLog("[EatvioVoIP] prepare call audio category failed: \(error)")
}
} }
private func activateInAppCallAudioSession() { private func activateInAppCallAudioSession() {
let rtcSession = RTCAudioSession.sharedInstance() let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true rtcSession.useManualAudio = true
rtcSession.lockForConfiguration() applyCallAudioConfiguration(active: true)
do {
try rtcSession.setConfiguration(callAudioConfiguration(), active: true)
} catch {
NSLog("[EatvioVoIP] activate call audio session failed: \(error)")
}
rtcSession.unlockForConfiguration()
// Starting the audio unit while the configuration lock is held deadlocks // Starting the audio unit while the configuration lock is held deadlocks
// the capture thread, so the mic stays silent. // the capture thread, so the mic stays silent.
rtcSession.isAudioEnabled = true rtcSession.isAudioEnabled = true
} }
/// Cycles the audio unit after the microphone track exists. Enabling audio /// Restarts capture after the previous audio graph has disconnected.
/// before the track is created leaves capture attached to nothing. /// Toggling the unit in the same turn makes voice processing throw.
private func restartInAppCallAudioUnit() { private func restartInAppCallAudioUnit() {
activateInAppCallAudioSession() audioRestartGeneration += 1
let generation = audioRestartGeneration
let rtcSession = RTCAudioSession.sharedInstance() let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.useManualAudio = true
rtcSession.isAudioEnabled = false 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() { private func deactivateInAppCallAudioSession() {
resumeAudioAfterCallKitEnds = false resumeAudioAfterCallKitEnds = false
audioRestartGeneration += 1
let rtcSession = RTCAudioSession.sharedInstance() let rtcSession = RTCAudioSession.sharedInstance()
rtcSession.isAudioEnabled = false rtcSession.isAudioEnabled = false
rtcSession.lockForConfiguration() 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 input on the receiver, so the mic is silent once audio goes to
// the loudspeaker. // the loudspeaker.
configuration.mode = AVAudioSession.Mode.videoChat.rawValue 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 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) {}
}

View File

@@ -27,7 +27,7 @@ class ChatsCubit extends Cubit<ChatsState> {
/// Test seam. Production uses CallKit. /// Test seam. Production uses CallKit.
Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall; Future<void> Function(VideoCallPushPayload payload)? presentIncomingVideoCall;
Future<void> Function()? dismissIncomingVideoCall; Future<void> Function(String? conversationUuid)? dismissIncomingVideoCall;
ChatsCubit({ ChatsCubit({
required this.chatRepository, required this.chatRepository,
@@ -230,7 +230,7 @@ class ChatsCubit extends Cubit<ChatsState> {
}) { }) {
final kind = VideoCallChatSignal.kindOf(message); final kind = VideoCallChatSignal.kindOf(message);
if (kind == VideoCallChatSignalKind.ended) { if (kind == VideoCallChatSignalKind.ended) {
unawaited(_dismissIncomingVideoCallUi()); unawaited(_dismissIncomingVideoCallUi(message.roomId));
unawaited( unawaited(
VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId), VideoCallEndedPushHandler.endActiveCallIfMatches(message.roomId),
); );
@@ -260,10 +260,16 @@ class ChatsCubit extends Cubit<ChatsState> {
} }
} }
Future<void> _dismissIncomingVideoCallUi() async { Future<void> _dismissIncomingVideoCallUi(String? conversationUuid) async {
try { try {
await (dismissIncomingVideoCall ?? final dismiss = dismissIncomingVideoCall;
VideoCallEndedPushHandler.dismissIncomingCallUi)(); if (dismiss != null) {
await dismiss(conversationUuid);
return;
}
await VideoCallEndedPushHandler.dismissIncomingCallUi(
conversationUuid: conversationUuid,
);
} catch (e, st) { } catch (e, st) {
log( log(
'ChatsCubit: failed to dismiss incoming video call: $e', 'ChatsCubit: failed to dismiss incoming video call: $e',

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:eatvio_app/core/locator_service.dart'; 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:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:flutter/services.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'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
/// Dismisses incoming call UI after a remote `video_call_ended` push. /// Dismisses incoming call UI after a remote `video_call_ended` push.
@@ -11,9 +12,7 @@ abstract final class VideoCallEndedPushHandler {
'com.eatvioapp/video_call_push', 'com.eatvioapp/video_call_push',
); );
static Future<void> dismissIncomingCallUi({ static Future<void> dismissIncomingCallUi({String? conversationUuid}) async {
String? conversationUuid,
}) async {
await endActiveCallIfMatches(conversationUuid); await endActiveCallIfMatches(conversationUuid);
if (Platform.isAndroid) { if (Platform.isAndroid) {
try { try {
@@ -22,7 +21,7 @@ abstract final class VideoCallEndedPushHandler {
} catch (_) {} } catch (_) {}
} }
await _dismissViaCallKit(); await _dismissViaCallKit(conversationUuid);
} }
static Future<void> endActiveCallIfMatches(String? conversationUuid) async { static Future<void> endActiveCallIfMatches(String? conversationUuid) async {
@@ -37,18 +36,37 @@ abstract final class VideoCallEndedPushHandler {
} catch (_) {} } catch (_) {}
} }
static Future<void> _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<void> _dismissViaCallKit(String? conversationUuid) async {
final uuid = conversationUuid?.trim();
try { try {
final activeCalls = await FlutterCallkitIncoming.activeCalls(); final activeCalls = await FlutterCallkitIncoming.activeCalls();
if (uuid == null || uuid.isEmpty) {
await FlutterCallkitIncoming.endAllCalls();
return;
}
for (final call in activeCalls) { for (final call in activeCalls) {
if (!callMatchesConversation(call, uuid)) continue;
try { try {
await FlutterCallkitIncoming.endCall(call.id); await FlutterCallkitIncoming.endCall(call.id);
} catch (_) {} } catch (_) {}
} }
} catch (_) {} } catch (_) {}
try {
await FlutterCallkitIncoming.endAllCalls();
} catch (_) {}
} }
} }

View File

@@ -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_kit_id.dart';
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.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/entities/entities.dart';
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
typedef VideoCallIncomingClaim = Future<bool> Function(String callId);
typedef VideoCallIncomingShow = Future<void> Function(CallKitParams params);
typedef VideoCallIncomingDelay = Future<void> 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 { abstract final class VideoCallIncomingPushPresenter {
static const String _androidAccentColor = '#2787F5'; static const String _androidAccentColor = '#2787F5';
static const MethodChannel _voipChannel = MethodChannel(
'com.eatvioapp/voip_diagnostics',
);
static Future<void> showIncomingCall(VideoCallPushPayload payload) async { /// Absolute delays from the start of an iOS present attempt.
static const List<Duration> iosClaimPollDelays = <Duration>[
Duration.zero,
Duration(milliseconds: 300),
Duration(milliseconds: 800),
Duration(milliseconds: 1500),
];
static Future<void> showIncomingCall(
VideoCallPushPayload payload, {
bool? isIos,
VideoCallIncomingClaim? isIncomingCallClaimed,
VideoCallIncomingClaim? reserveIncomingCall,
VideoCallIncomingShow? showCallkitIncoming,
VideoCallIncomingDelay? delay,
List<Duration>? claimPollDelays,
}) async {
final callerName = payload.counterpartName?.trim().isNotEmpty == true final callerName = payload.counterpartName?.trim().isNotEmpty == true
? payload.counterpartName!.trim() ? payload.counterpartName!.trim()
: 'Итвио'; : 'Итвио';
final ios = isIos ?? Platform.isIOS;
final callId = Platform.isIOS final callId = ios
? VideoCallKitId.iosUuid( ? VideoCallKitId.iosUuid(
conversationUuid: payload.conversationUuid, conversationUuid: payload.conversationUuid,
messageId: payload.messageId, messageId: payload.messageId,
@@ -21,6 +51,22 @@ abstract final class VideoCallIncomingPushPresenter {
payload.conversationUuid payload.conversationUuid
: payload.conversationUuid; : payload.conversationUuid;
if (ios) {
final claimed = isIncomingCallClaimed ?? _isIncomingCallClaimed;
final reserve = reserveIncomingCall ?? _reserveIncomingCall;
final wait = delay ?? Future<void>.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( final params = CallKitParams(
id: callId, id: callId,
nameCaller: callerName, nameCaller: callerName,
@@ -57,10 +103,42 @@ abstract final class VideoCallIncomingPushPresenter {
ios: const IOSParams( ios: const IOSParams(
handleType: 'generic', handleType: 'generic',
supportsVideo: true, supportsVideo: true,
configureAudioSession: false,
audioSessionActive: false,
audioSessionMode: 'videoChat',
ringtonePath: 'system_ringtone_default', ringtonePath: 'system_ringtone_default',
), ),
); );
await FlutterCallkitIncoming.showCallkitIncoming(params); await (showCallkitIncoming ?? FlutterCallkitIncoming.showCallkitIncoming)(
params,
);
}
static Future<bool> _claimOrFalse(
VideoCallIncomingClaim claim,
String callId,
) async {
try {
return await claim(callId);
} catch (_) {
return false;
}
}
static Future<bool> _isIncomingCallClaimed(String callId) async {
final claimed = await _voipChannel.invokeMethod<bool>(
'isIncomingCallClaimed',
<String, String>{'id': callId},
);
return claimed ?? false;
}
static Future<bool> _reserveIncomingCall(String callId) async {
final reserved = await _voipChannel.invokeMethod<bool>(
'reserveIncomingCall',
<String, String>{'id': callId},
);
return reserved ?? false;
} }
} }

View File

@@ -1,6 +1,6 @@
name: eatvio_app name: eatvio_app
description: A new Flutter project. description: A new Flutter project.
version: 1.3.53+142 version: 1.3.54+143
publish_to: 'none' publish_to: 'none'
environment: environment:

View File

@@ -479,6 +479,39 @@ void main() {
}, },
); );
test(
'_handleNewMessageViaSocket - завершение видеозвонка закрывает только этот чат',
() async {
const chatId = 'chat-id';
final dismissed = <String?>[];
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 { test('close - закрывает cubit и отписывается от подписок', () async {
final mockSubscription1 = MockStreamSubscription(); final mockSubscription1 = MockStreamSubscription();

View File

@@ -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: <String, dynamic>{'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,
);
});
});
}

View File

@@ -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 = <CallKitParams>[];
final delays = <Duration>[];
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 = <CallKitParams>[];
var polls = 0;
await VideoCallIncomingPushPresenter.showIncomingCall(
payload,
isIos: true,
claimPollDelays: const <Duration>[
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 = <CallKitParams>[];
var polls = 0;
await VideoCallIncomingPushPresenter.showIncomingCall(
payload,
isIos: true,
claimPollDelays: const <Duration>[
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 = <CallKitParams>[];
await VideoCallIncomingPushPresenter.showIncomingCall(
payload,
isIos: true,
claimPollDelays: const <Duration>[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 = <CallKitParams>[];
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');
},
);
});
}