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