forked from eatvio/eatvio_app
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:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user