import Foundation import UIKit enum VideoCallDeclineClient { private static let uuidPattern = #"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"# /// `POST /video-call/decline` from the CallKit callback. The process is /// awake here even when the user never opens the app afterwards. static func send(extra: [String: Any], handle: String) { let fields = declineFields(extra: extra, handle: handle) guard let request = makeRequest( baseUrl: prefsString("apiBaseUrl") ?? prefsString("customBaseUrl"), token: prefsString("token"), hwId: UIDevice.current.identifierForVendor?.uuidString, fields: fields ) else { NSLog( "[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)" ) return } let task = EatvioBackgroundTask() task.begin(name: "video-call-decline") let urlTask = URLSession.shared.dataTask(with: request) { _, response, error in let code = (response as? HTTPURLResponse)?.statusCode ?? -1 NSLog( "[EatvioVoIP] decline POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")" ) task.end() } urlTask.resume() } static func makeRequest( baseUrl: String?, token: String?, hwId: String?, fields: VideoCallDeclineFields ) -> URLRequest? { if fields.endedPush { return nil } let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) let authToken = token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let deviceId = hwId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" var root = baseUrl?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" while root.hasSuffix("/") { root.removeLast() } guard !conversation.isEmpty, !authToken.isEmpty else { return nil } guard deviceId.count >= 8, deviceId.count <= 64 else { return nil } guard root.hasPrefix("http://") || root.hasPrefix("https://") else { return nil } guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline") else { return nil } var body: [String: String] = ["hw_id": deviceId] if let callId = fields.callId { body["call_id"] = callId } guard let data = try? JSONSerialization.data(withJSONObject: body) else { return nil } var request = URLRequest(url: url) request.httpMethod = "POST" request.timeoutInterval = 10 request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") request.httpBody = data return request } private static func declineFields(extra: [String: Any], handle: String) -> VideoCallDeclineFields { let conversation = firstString(extra, keys: ["conversationUuid", "conversation_uuid"]) ?? handle.trimmingCharacters(in: .whitespacesAndNewlines) let rawCallId = firstString(extra, keys: ["call_id", "callId"]) return VideoCallDeclineFields( conversationUuid: conversation, callId: backendCallId(rawCallId, conversation: conversation), endedPush: marksEndedPush(extra["endedPush"]) ) } private static func backendCallId(_ callId: String?, conversation: String) -> String? { let id = callId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" guard let regex = try? NSRegularExpression(pattern: uuidPattern), regex.firstMatch(in: id, range: NSRange(id.startIndex..., in: id)) != nil else { return nil } if id.compare(conversation.trimmingCharacters(in: .whitespacesAndNewlines), options: .caseInsensitive) == .orderedSame { return nil } return id } private static func firstString(_ extra: [String: Any], keys: [String]) -> String? { for key in keys { guard let value = extra[key] as? String else { continue } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } return nil } private static func marksEndedPush(_ value: Any?) -> Bool { if let bool = value as? Bool { return bool } if let number = value as? NSNumber { return number.intValue != 0 } if let text = value as? String { let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return normalized == "true" || normalized == "1" } return false } private static func prefsString(_ key: String) -> String? { let value = UserDefaults.standard.string(forKey: "flutter.\(key)")? .trimmingCharacters(in: .whitespacesAndNewlines) if let value, !value.isEmpty { return value } return nil } } struct VideoCallDeclineFields { var conversationUuid: String var callId: String? var endedPush: Bool } private final class EatvioBackgroundTask { private let lock = NSLock() private var id: UIBackgroundTaskIdentifier = .invalid func begin(name: String) { let taskId = UIApplication.shared.beginBackgroundTask(withName: name) { [weak self] in self?.end() } lock.lock() id = taskId lock.unlock() } func end() { lock.lock() let current = id id = .invalid lock.unlock() guard current != .invalid else { return } UIApplication.shared.endBackgroundTask(current) } }