call declined when app is killed.

main_production_audio_video_call
Sikander Saleem 2 weeks ago
parent 0a72638188
commit 42effa2db0

@ -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,

@ -59,9 +59,37 @@ Future<void> 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<CallEvent?>? _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<void> notifyCallerDeclined(Map<String, dynamic> 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<void> notifyCallerDeclined(Map<String, dynamic> 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<void> handleDataMessage(Map<String, dynamic> 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,

@ -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();

@ -63,6 +63,7 @@ class _LandPageState extends State<LandPage> {
@override
void dispose() {
_callEventSubscription?.cancel();
if (CallKitEvents.uiHandler == _onCallEvent) CallKitEvents.uiHandler = null;
super.dispose();
}
@ -225,33 +226,44 @@ class _LandPageState extends State<LandPage> {
}
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<void> _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 ?? {});
}

Loading…
Cancel
Save