Merge pull request 'feat(video_calls): ios mic fix, tokens clearing fix' (#1091) from misha/eatvio_app:issue-1089 into main

Reviewed-on: eatvio/eatvio_app#1091
Reviewed-by: Анатолий Полоз <t0ly.od@ya.ru>
This commit is contained in:
2026-10-21 13:28:50 +00:00
7 changed files with 375 additions and 76 deletions

View File

@@ -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 {

View File

@@ -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<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
try {
await sl<Dio>().post('${sl<AppConfig>().baseUrl}/api/profile/logout');
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
} finally {
try {
// Needs Authorization header → must run before removing token.
await sl<FirebaseApi>().unregisterPushTokens();
} catch (e, s) {
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
}
await sl<VideoCallSessionCubit>().endCall();
await sl<WsManager>().clear();
await TokenStorage.removeTokenInStorage();

View File

@@ -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<void> _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 (_) {}
}
}

View File

@@ -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<void> _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 <String, Object?>{}) ??
final callId =
_parseCallId(params?.extra ?? const <String, Object?>{}) ??
(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<void>.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<void>.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<void> _finishCallKitAfterAccept(String callKitId) async {
if (Platform.isIOS) {
try {
await FlutterCallkitIncoming.setCallConnected(callKitId);
} catch (_) {}
return;
}
_suppressedEndCallKitIds.add(callKitId);
await FlutterCallkitIncoming.endCall(callKitId);
}
Future<void> _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;
}

View File

@@ -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<void> dismissCallKitOnly({
String? conversationUuid,
String? callId,
}) {
return _dismissViaCallKit(
conversationUuid: conversationUuid,
callId: callId,
);
}
static Future<void> endActiveCallIfMatches({
String? conversationUuid,
String? callId,

View File

@@ -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:

View File

@@ -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, <String>['ringing-id']);
expect(VideoCallRemoteEndRegistry.contains('ringing-id'), isTrue);
});
});
}