diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index dd8e32f7..742bcda5 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,10 +2,12 @@ import UIKit import Flutter import Firebase import PushKit +import CallKit +import AVFAudio import flutter_callkit_incoming @main -@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate { +@objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate { // Expose the Flutter engine for SceneDelegate to use lazy var flutterEngine = FlutterEngine(name: "my flutter engine") @@ -13,6 +15,9 @@ import flutter_callkit_incoming // Must be retained for the lifetime of the app, otherwise VoIP pushes stop. private var voipRegistry: PKPushRegistry? + // Keeps the app alive briefly after a decline so Dart can notify the caller. + private var declineTask: UIBackgroundTaskIdentifier = .invalid + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -26,6 +31,7 @@ import flutter_callkit_incoming // Register plugins with the engine GeneratedPluginRegistrant.register(with: self.flutterEngine) + // Register for VoIP pushes AFTER plugins, so flutter_callkit_incoming is ready. let registry = PKPushRegistry(queue: .main) registry.delegate = self @@ -46,6 +52,51 @@ import flutter_callkit_incoming return sceneConfiguration } + // MARK: - CallkitIncomingAppDelegate + // The plugin hands these actions to the app delegate; every one must be fulfilled + // (that is what the plugin does itself when the delegate does not conform). + + func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + action.fulfill() + } + + func onDecline(_ call: Call, _ action: CXEndCallAction) { + action.fulfill() + // App launched in the background by a VoIP push (killed state): once the call is declined + // iOS may suspend the app almost immediately. Keep it alive briefly so the Dart decline + // handler (CallKitEvents in firebase_notification_manger.dart) can notify the caller. + beginDeclineBackgroundTask() + } + + func onEnd(_ call: Call, _ action: CXEndCallAction) { + action.fulfill() + } + + func onTimeOut(_ call: Call) {} + + func didActivateAudioSession(_ audioSession: AVAudioSession) {} + + func didDeactivateAudioSession(_ audioSession: AVAudioSession) {} + + func providerDidReset() {} + + private func beginDeclineBackgroundTask() { + endDeclineBackgroundTask() + declineTask = UIApplication.shared.beginBackgroundTask(withName: "CallDecline") { [weak self] in + self?.endDeclineBackgroundTask() + } + // Enough for the token request + SignalR connect + CallDeclinedAsync. + DispatchQueue.main.asyncAfter(deadline: .now() + 20) { [weak self] in + self?.endDeclineBackgroundTask() + } + } + + private func endDeclineBackgroundTask() { + guard declineTask != .invalid else { return } + UIApplication.shared.endBackgroundTask(declineTask) + declineTask = .invalid + } + // MARK: - PushKit (VoIP) func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) { @@ -157,6 +208,11 @@ import flutter_callkit_incoming "supportsVideo": true, "maximumCallGroups": 1, "maximumCallsPerCallGroup": 1, + // Don't let the plugin activate a playAndRecord audio session while ringing: while the app + // holds an active session, iOS routes the volume/side buttons to it instead of the CallKit + // call (no silence on press, no decline on double-press). CallKit activates audio on + // answer and flutter_webrtc configures it for the call. + "configureAudioSession": false, "audioSessionMode": "default", "audioSessionActive": true, "audioSessionPreferredSampleRate": 44100.0, diff --git a/lib/controllers/notification/firebase_notification_manger.dart b/lib/controllers/notification/firebase_notification_manger.dart index 580db5d2..c9ddab66 100644 --- a/lib/controllers/notification/firebase_notification_manger.dart +++ b/lib/controllers/notification/firebase_notification_manger.dart @@ -59,9 +59,37 @@ Future onBackgroundMessage(CallEvent event) async { } } +/// iOS: the single app-wide subscription to [FlutterCallkitIncoming.onEvent], started in main(). +/// +/// When the app is killed, a VoIP push launches it in the background with no UI: the splash +/// animation never completes, LandPage is never built and its listener never exists, so a +/// Decline tapped on the CallKit screen was dropped and the caller kept ringing. +/// Starting this in main() means a listener exists before the user can respond. +/// +/// It must be the ONLY onEvent subscription: every `onEvent` getter call creates a new +/// EventChannel stream, and listening again replaces the previous native handler. +/// LandPage therefore receives events through [uiHandler] instead of listening itself. +class CallKitEvents { + static StreamSubscription? _subscription; + + /// Set by LandPage while it is mounted; receives every event after this class handled it. + static void Function(CallEvent event)? uiHandler; + + static void startIOS() { + if (!Platform.isIOS || _subscription != null) return; + _subscription = FlutterCallkitIncoming.onEvent.listen((CallEvent? event) { + if (event == null) return; + if (event case CallEventActionCallDecline(:final callKitParams)) { + // Works without UI or login state; AppDelegate keeps the app alive while it runs. + notifyCallerDeclined(callKitParams.extra ?? {}); + } + uiHandler?.call(event); + }); + } +} + /// Tells the caller the call was declined (same signalling as `_handleCallDeclined` in land_page.dart). Future notifyCallerDeclined(Map extra, {String? reason}) async { - print("notifyCallerDeclined:$extra"); final callee = extra["calleeEmployeeNumber"]?.toString() ?? ''; final caller = extra["callerEmployeeNumber"]?.toString() ?? ''; if (callee.isEmpty || caller.isEmpty) return; @@ -71,7 +99,7 @@ Future notifyCallerDeclined(Map extra, {String? reason}) await provider.declineCall(callee, caller, moduleId: int.tryParse(extra["moduleId"]?.toString() ?? '') ?? 0, reason: reason); } } catch (_) { - print("errorDecline:$_"); + // Best effort - the caller side also times out. } finally { try { await provider.endSession(); @@ -485,6 +513,9 @@ Future handleDataMessage(Map messageData, bool isFromBack supportsVideo: true, maximumCallGroups: 1, maximumCallsPerCallGroup: 1, + // Same as AppDelegate.swift: no app-owned audio session while ringing, so the hardware + // buttons control the CallKit call (silence / double-press decline). + configureAudioSession: false, audioSessionMode: 'default', audioSessionActive: true, audioSessionPreferredSampleRate: 44100.0, diff --git a/lib/main.dart b/lib/main.dart index 797e49ef..47268a1d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -161,6 +161,9 @@ void main() async { if (Platform.isAndroid) { FlutterCallkitIncoming.onBackgroundMessage(onBackgroundMessage); } + // iOS: listen for CallKit events before anything else, so a Decline on a call that launched + // the app from killed state (no UI yet) still reaches the caller. + CallKitEvents.startIOS(); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); // HttpOverrides.global = MyHttpOverrides(); // for later use. _configureLocalTimeZone(); diff --git a/lib/new_views/pages/land_page/land_page.dart b/lib/new_views/pages/land_page/land_page.dart index 040ebfd9..764050ad 100644 --- a/lib/new_views/pages/land_page/land_page.dart +++ b/lib/new_views/pages/land_page/land_page.dart @@ -63,6 +63,7 @@ class _LandPageState extends State { @override void dispose() { _callEventSubscription?.cancel(); + if (CallKitEvents.uiHandler == _onCallEvent) CallKitEvents.uiHandler = null; super.dispose(); } @@ -225,33 +226,44 @@ class _LandPageState extends State { } callingEvents(BuildContext context) { + // iOS: main() owns the only onEvent subscription (CallKitEvents) - a second listen here would + // replace it. Receive events through its uiHandler instead. + if (Platform.isIOS) { + CallKitEvents.uiHandler = _onCallEvent; + return; + } _callEventSubscription?.cancel(); _callEventSubscription = FlutterCallkitIncoming.onEvent.listen((CallEvent? event) { - if (event == null) return; - print("callingEvents:$event"); - switch (event) { - case CallEventActionCallAccept(:final callKitParams): - _handleCallAccepted(callKitParams); + if (event != null) _onCallEvent(event); + }); + } - case CallEventActionCallDecline(:final callKitParams): - _handleCallDeclined(callKitParams); + void _onCallEvent(CallEvent event) { + print("callingEvents:$event"); + switch (event) { + case CallEventActionCallAccept(:final callKitParams): + _handleCallAccepted(callKitParams); - case CallEventActionCallEnded(:final callKitParams): - _handleCallEnded(callKitParams); + case CallEventActionCallDecline(:final callKitParams): + _handleCallDeclined(callKitParams); - case CallEventActionDidUpdateDevicePushTokenVoip(): - FirebaseNotificationManger.refreshVoipToken(); + case CallEventActionCallEnded(:final callKitParams): + _handleCallEnded(callKitParams); - // Informational events (several fire on every iOS call) - nothing to do. - default: - break; - } - }); + case CallEventActionDidUpdateDevicePushTokenVoip(): + FirebaseNotificationManger.refreshVoipToken(); + + // Informational events (several fire on every iOS call) - nothing to do. + default: + break; + } } Future _handleCallDeclined(CallKitParams callKitParams) async { FirebaseNotificationManger.cancelMissedCallTimer(callKitParams.extra?["callId"] as String?); FlutterCallkitIncoming.endCall(callKitParams.extra?["callId"]); + // iOS: CallKitEvents (main.dart) already notified the caller for every decline. + if (Platform.isIOS) return; // Uses its own short-lived CallProvider - the app-wide one must not be disposed. await notifyCallerDeclined(callKitParams.extra ?? {}); }