diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 488ea9c0..8dcf4f5f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -437,73 +437,21 @@ import flutter_foreground_task return payload } - private func bringAppToForeground() { - DispatchQueue.main.async { - for scene in UIApplication.shared.connectedScenes { - guard let windowScene = scene as? UIWindowScene else { continue } - for window in windowScene.windows { - window.makeKeyAndVisible() - } - } - self.window?.makeKeyAndVisible() - } - } - - private func openAppForAcceptedCall(_ call: Call) { - guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else { - recordVoipDiagnostic( - "accepted_call_missing_conversation_uuid", - data: ["callKitId": call.data.uuid] - ) + /// The answer callback runs before the scene is visible. A custom-scheme + /// open from here is an untrusted user action and SpringBoard denies it. + /// CallKit foregrounds a video call itself after `fulfill`. Flutter reads + /// the stored payload once that scene is actually active. + func deliverStoredAcceptedCall() { + guard voipDiagnosticsChannel != nil else { return } + guard let payload = UserDefaults.standard.dictionary(forKey: pendingAcceptedVoipCallKey) else { return } - - var components = URLComponents() - components.scheme = appUrlScheme - components.host = "video-call" - 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( - "accepted_call_url_build_failed", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - ] - ) + let acceptedAt = (payload[pendingAcceptedAtKey] as? NSNumber)?.doubleValue + if let acceptedAt, Date().timeIntervalSince1970 - acceptedAt > pendingAcceptedCallTtl { + UserDefaults.standard.removeObject(forKey: pendingAcceptedVoipCallKey) return } - - recordVoipDiagnostic( - "accepted_call_open_app_requested", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - "url": url.absoluteString, - "appState": UIApplication.shared.applicationState.rawValue, - ] - ) - - bringAppToForeground() - - UIApplication.shared.open(url, options: [:]) { [weak self] success in - self?.recordVoipDiagnostic( - success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - "url": url.absoluteString, - ] - ) - } + voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: payload) } private func sanitizedDiagnosticValue(_ value: Any?) -> Any { @@ -1177,6 +1125,8 @@ import flutter_foreground_task // activation and can start I/O before didActivate. prepareAvAudioSessionCategory() storePendingAcceptedCall(call) + let extra = call.data.extra as? [String: Any] ?? [:] + VideoCallDeclineClient.sendAccept(extra: extra, handle: call.data.handle) recordVoipDiagnostic( "callkit_accept_native", data: [ @@ -1185,11 +1135,7 @@ import flutter_foreground_task "data": call.data.toJSON(), ] ) - voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) - DispatchQueue.main.async { [weak self] in - self?.bringAppToForeground() - self?.openAppForAcceptedCall(call) - } + deliverStoredAcceptedCall() action.fulfill() } diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift index f27a5751..fd717aed 100644 --- a/ios/Runner/SceneDelegate.swift +++ b/ios/Runner/SceneDelegate.swift @@ -19,6 +19,10 @@ class SceneDelegate: FlutterSceneDelegate { override func sceneDidBecomeActive(_ scene: UIScene) { super.sceneDidBecomeActive(scene) registerCallChannels() + guard let appDelegate = UIApplication.shared.delegate as? AppDelegate else { + return + } + appDelegate.deliverStoredAcceptedCall() } override func scene(_ scene: UIScene, openURLContexts URLContexts: Set) { diff --git a/ios/Runner/VideoCallDeclineClient.swift b/ios/Runner/VideoCallDeclineClient.swift index fb24ce50..2e3a20d7 100644 --- a/ios/Runner/VideoCallDeclineClient.swift +++ b/ios/Runner/VideoCallDeclineClient.swift @@ -8,25 +8,37 @@ enum VideoCallDeclineClient { /// `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) { + post(action: "decline", extra: extra, handle: handle) + } + + /// `POST /video-call/accept` from the CallKit answer callback. Flutter may + /// still be frozen on the splash snapshot, so the other device must be + /// told from here. A later Dart retry with the same hw_id is idempotent. + static func sendAccept(extra: [String: Any], handle: String) { + post(action: "accept", extra: extra, handle: handle) + } + + private static func post(action: String, 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 + fields: fields, + action: action ) else { NSLog( - "[EatvioVoIP] decline POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)" + "[EatvioVoIP] \(action) POST skipped conversation=\(fields.conversationUuid) ended=\(fields.endedPush)" ) return } let task = EatvioBackgroundTask() - task.begin(name: "video-call-decline") + task.begin(name: "video-call-\(action)") 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 ?? "-")" + "[EatvioVoIP] \(action) POST status=\(code) conversation=\(fields.conversationUuid) error=\(error?.localizedDescription ?? "-")" ) task.end() } @@ -37,7 +49,8 @@ enum VideoCallDeclineClient { baseUrl: String?, token: String?, hwId: String?, - fields: VideoCallDeclineFields + fields: VideoCallDeclineFields, + action: String = "decline" ) -> URLRequest? { if fields.endedPush { return nil } let conversation = fields.conversationUuid.trimmingCharacters(in: .whitespacesAndNewlines) @@ -50,8 +63,9 @@ enum VideoCallDeclineClient { 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 action == "accept" || action == "decline" else { return nil } guard let encoded = conversation.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), - let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/decline") + let url = URL(string: "\(root)/api/v2/chat/\(encoded)/video-call/\(action)") else { return nil } var body: [String: String] = ["hw_id": deviceId] diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 5d9b456a..ddfe654d 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -52,6 +52,7 @@ class VideoCallCallKitCoordinator { _PendingIncomingAccept? _pendingIncomingAccept; _AndroidAcceptResumeObserver? _androidResumeObserver; + _AppResumeObserver? _iosResumeObserver; bool _pendingAcceptDrainScheduled = false; Future initialize() async { @@ -86,6 +87,12 @@ class VideoCallCallKitCoordinator { } }); await _firebaseApi.registerPushToken(); + _iosResumeObserver = _AppResumeObserver( + onResumed: () { + unawaited(_drainPendingNativeAcceptedVoipCall()); + }, + ); + WidgetsBinding.instance.addObserver(_iosResumeObserver!); await _drainPendingNativeAcceptedVoipCall(); WidgetsBinding.instance.addPostFrameCallback((_) { _scheduleNativeVoipDiagnosticsDrain(); @@ -257,6 +264,11 @@ class VideoCallCallKitCoordinator { WidgetsBinding.instance.removeObserver(observer); _androidResumeObserver = null; } + final iosObserver = _iosResumeObserver; + if (iosObserver != null) { + WidgetsBinding.instance.removeObserver(iosObserver); + _iosResumeObserver = null; + } unawaited(_eventSubscription?.cancel()); _eventSubscription = null; _callKitParamsById.clear(); @@ -1228,6 +1240,19 @@ class _PendingIncomingAccept { final bool? counterpartIsConsultant; } +class _AppResumeObserver with WidgetsBindingObserver { + _AppResumeObserver({required this.onResumed}); + + final VoidCallback onResumed; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + onResumed(); + } + } +} + class _AndroidAcceptResumeObserver with WidgetsBindingObserver { _AndroidAcceptResumeObserver({required this.onResumed}); diff --git a/pubspec.yaml b/pubspec.yaml index 4f5d4984..4d9cf965 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.68+157 +version: 1.3.69+158 publish_to: 'none' environment: