I am working on an iOS app using Flutter that tracks outgoing calls using CallKit. The call tracking functionality works perfectly in Debug mode but does not work when the app is published to TestFlight.
I have already added Background Modes (voip, audio, processing, fetch) in Info.plist. I have added CallKit.framework in Xcode under Link Binary With Libraries (set to Optional). I have also added the required entitlements in Runner.entitlements:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ".0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
</dict>
</plist>
These are the necessary permission which I used in info.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ".0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.agent.mygenie</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>MyGenie</string>
<key>CFBundleDocumentTypes</key>
<array/>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>mygenie</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCallKitUsageDescription</key>
<string>This app needs access to CallKit for call handling</string>
<key>NSContactsUsageDescription</key>
<string>This app needs access to your contacts for calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app needs access to microphone for calls</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to photo library for profile picture updation</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
<string>processing</string>
<string>fetch</string>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
This is the app delegate.swift file code :-
import Flutter
import UIKit
import CallKit
import AVFoundation
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
// MARK: - Properties
private var callObserver: CXCallObserver?
private var callStartTime: Date?
private var flutterChannel: FlutterMethodChannel?
private var isCallActive = false
private var currentCallDuration: Int = 0
private var callTimer: Timer?
private var lastKnownDuration: Int = 0
private var isOutgoingCall = false
// MARK: - Application Lifecycle
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Ensure window and root view controller are properly set up
guard let controller = window?.rootViewController as? FlutterViewController else {
print("Failed to get FlutterViewController")
return false
}
// Setup Flutter plugins
do {
try GeneratedPluginRegistrant.register(with: self)
} catch {
print("Failed to register Flutter plugins: \(error)")
return false
}
// Setup method channel
setupMethodChannel(controller: controller)
// Setup call observer
setupCallObserver()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// MARK: - Private Methods
private func setupMethodChannel(controller: FlutterViewController) {
flutterChannel = FlutterMethodChannel(
name: "callkit_channel",
binaryMessenger: controller.binaryMessenger
)
flutterChannel?.setMethodCallHandler { [weak self] (call, result) in
self?.handleMethodCall(call, result: result)
}
}
private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "checkCallStatus":
result([
"isActive": isCallActive,
"duration": currentCallDuration,
"isOutgoing": isOutgoingCall
])
case "getCurrentDuration":
result(currentCallDuration)
case "requestPermissions":
requestPermissions(result: result)
case "initiateOutgoingCall":
isOutgoingCall = true
result(true)
default:
result(FlutterMethodNotImplemented)
}
}
private func setupCallObserver() {
print("Inside the call observer setup")
#if DEBUG
callObserver = CXCallObserver()
callObserver?.setDelegate(self, queue: .main)
print("Call Kit functionality is enabled for this fake environment")
#else
// Check if the app is running in a release environment
if Bundle.main.bundleIdentifier == "com.agent.mygenie" {
callObserver = CXCallObserver()
callObserver?.setDelegate(self, queue: .main)
print("Call Kit functionality is enabled for this prod environment")
} else {
print("Call Kit functionality is not enabled for this environment")
}
#endif
// callObserver = CXCallObserver()
// callObserver?.setDelegate(self, queue: .main)
}
private func startCallTimer() {
guard isOutgoingCall else { return }
print("Starting call timer for outgoing call")
callTimer?.invalidate()
currentCallDuration = 0
callStartTime = Date()
lastKnownDuration = 0
callTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateCallDuration()
}
}
private func updateCallDuration() {
guard let startTime = callStartTime else { return }
currentCallDuration = Int(Date().timeIntervalSince(startTime))
lastKnownDuration = currentCallDuration
print("Current duration: \(currentCallDuration)")
flutterChannel?.invokeMethod("onCallDurationUpdate", arguments: [
"duration": currentCallDuration,
"isOutgoing": true
])
}
private func stopCallTimer() {
guard isOutgoingCall else { return }
print("Stopping call timer")
callTimer?.invalidate()
callTimer = nil
if let startTime = callStartTime {
let finalDuration = Int(Date().timeIntervalSince(startTime))
currentCallDuration = max(finalDuration, lastKnownDuration)
print("Final duration calculated: \(currentCallDuration)")
} else {
currentCallDuration = lastKnownDuration
print("Using last known duration: \(lastKnownDuration)")
}
}
private func requestPermissions(result: @escaping FlutterResult) {
AVAudioSession.sharedInstance().requestRecordPermission { granted in
DispatchQueue.main.async {
print("Microphone permission granted: \(granted)")
result(granted)
}
}
}
private func resetCallState() {
isCallActive = false
isOutgoingCall = false
currentCallDuration = 0
lastKnownDuration = 0
callStartTime = nil
callTimer?.invalidate()
callTimer = nil
}
}
// MARK: - CXCallObserverDelegate
extension AppDelegate: CXCallObserverDelegate {
func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) {
// Update outgoing call status if needed
if !isOutgoingCall {
isOutgoingCall = call.isOutgoing
}
// Only process outgoing calls
guard isOutgoingCall else {
print("Ignoring incoming call")
return
}
handleCallStateChange(call)
}
private func handleCallStateChange(_ call: CXCall) {
if call.hasConnected && isOutgoingCall {
handleCallConnected()
}
if call.hasEnded && isOutgoingCall {
handleCallEnded()
}
}
private func handleCallConnected() {
print("Outgoing call connected")
isCallActive = true
startCallTimer()
flutterChannel?.invokeMethod("onCallStarted", arguments: [
"isOutgoing": true
])
}
private func handleCallEnded() {
print("Outgoing call ended")
isCallActive = false
stopCallTimer()
let finalDuration = max(currentCallDuration, lastKnownDuration)
print("Sending final duration: \(finalDuration)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.sendCallEndedEvent(duration: finalDuration)
}
}
private func sendCallEndedEvent(duration: Int) {
flutterChannel?.invokeMethod("onCallEnded", arguments: [
"duration": duration,
"isOutgoing": true
])
resetCallState()
}
}
// MARK: - CXCall Extension
extension CXCall {
var isOutgoing: Bool {
return hasConnected && !hasEnded
}
}
and this is how I setup it in flutter using method channel in a one mixing file to attach that file on a screen where I needed it :-
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'dart:io';
import 'package:get/get.dart';
import 'package:MyGenie/call_state.dart';
mixin CallTrackingMixin<T extends StatefulWidget> on State<T> {
final CallStateManager callStateManager = CallStateManager();
static const MethodChannel platform = MethodChannel('callkit_channel');
Timer? _callDurationTimer;
bool _isCallActive = false;
int _currentCallDuration = 0;
int _callTimeDuration = 0;
DateTime? _callStartTime;
StreamController<int>? _durationController;
int _lastKnownDuration = 0;
bool _isApiCalled = false;
@override
void initState() {
super.initState();
print("InitState - Setting up call monitoring");
_setupCallMonitoring();
print("Call monitoring setup completed");
}
@override
void dispose() {
_durationController?.close();
super.dispose();
}
Future<void> _setupCallMonitoring() async {
print("Setting up call monitoring");
_durationController?.close();
_durationController = StreamController<int>.broadcast();
platform.setMethodCallHandler((MethodCall call) async {
print("Method call received: ${call.method}");
if (!mounted) {
print("Widget not mounted, returning");
return;
}
switch (call.method) {
case 'onCallStarted':
print("Call started - Resetting states");
setState(() {
_isCallActive = true;
_callStartTime = DateTime.now();
_isApiCalled = false; // Reset here explicitly
});
print("Call states reset - isApiCalled: $_isApiCalled");
break;
case 'onCallEnded':
print("Call ended event received");
print("Current isApiCalled status: $_isApiCalled");
if (call.arguments != null) {
final Map<dynamic, dynamic> args = call.arguments;
final int duration = args['duration'] as int;
print("Processing call end with duration: $_callTimeDuration");
// Force reset isApiCalled here
setState(() {
_isApiCalled = false;
});
await _handleCallEnded(_currentCallDuration);
}
setState(() {
_isCallActive = false;
});
break;
case 'onCallDurationUpdate':
if (call.arguments != null && mounted) {
final Map<dynamic, dynamic> args = call.arguments;
final int duration = args['duration'] as int;
setState(() {
_currentCallDuration = duration;
_lastKnownDuration = duration;
_callTimeDuration = duration;
});
_durationController?.add(duration);
print("Duration update: $duration seconds");
}
break;
}
});
}
void resetCallState() {
print("Resetting call state");
setState(() {
_isApiCalled = false;
_isCallActive = false;
_currentCallDuration = 0;
_lastKnownDuration = 0;
_callTimeDuration = 0;
_callStartTime = null;
});
print("Call state reset completed - isApiCalled: $_isApiCalled");
}
Future<void> _handleCallEnded(int durationInSeconds) async {
print("Entering _handleCallEnded");
print("Current state - isApiCalled: $_isApiCalled, mounted: $mounted");
print("Duration to process: $durationInSeconds seconds");
// Force check and reset if needed
if (_isApiCalled) {
print("Resetting isApiCalled flag as it was true");
setState(() {
_isApiCalled = false;
});
}
if (mounted) {
final duration = Duration(seconds: durationInSeconds);
final formattedDuration = _formatDuration(duration);
print("Processing call end with duration: $formattedDuration");
if (durationInSeconds == 0 && _callStartTime != null) {
final fallbackDuration = DateTime.now().difference(_callStartTime!);
final fallbackSeconds = fallbackDuration.inSeconds;
print("Using fallback duration: $fallbackSeconds seconds");
await _saveCallDuration(fallbackSeconds);
} else {
print("Using provided duration: $durationInSeconds seconds");
await _saveCallDuration(durationInSeconds);
}
setState(() {
_isApiCalled = true;
});
print("Call processing completed - isApiCalled set to true");
} else {
print("Widget not mounted, skipping call processing");
}
}
Future<void> _saveCallDuration(int durationInSeconds) async {
if (durationInSeconds > 0) {
final formattedDuration =
_formatDuration(Duration(seconds: durationInSeconds));
if (callStateManager.callId.isNotEmpty) {
saveRandomCallDuration(formattedDuration);
}
if (callStateManager.leadCallId.isNotEmpty) {
saveCallDuration(formattedDuration);
}
} else {
print("Warning: Attempting to save zero duration");
}
}
void saveCallDuration(String duration);
void saveRandomCallDuration(String duration);
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
String hours =
duration.inHours > 0 ? '${twoDigits(duration.inHours)}:' : '';
String minutes = twoDigits(duration.inMinutes.remainder(60));
String seconds = twoDigits(duration.inSeconds.remainder(60));
return '$hours$minutes:$seconds';
}
void resetCallTracking() {
_setupCallMonitoring();
}
}
And this is the main_call.dart file code where I'm saving call duration to the database with api :-
@override
Future<void> saveRandomCallDuration(String duration) async {
await Sentry.captureMessage("save random call Duration :- ${duration} against this id :- ${callStateManager.callId}");
print(
"save random call Duration :- ${duration} against this id :- ${callStateManager.callId}");
try {
String token = await SharedPreferencesHelper.getFcmToken();
String apiUrl = ApiUrls.saveRandomCallDuration;
final response = await http.post(
Uri.parse(apiUrl),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode(<String, String>{
"id": callStateManager.callId,
"call_duration": duration
//default : lead call ; filters : random call
}),
);
if (response.statusCode == 200) {
_isApiCalled = true;
saveCallId = '';
callStateManager.clearCallId();
resetCallState();
setState(() {});
} else {
setState(() {
_isApiCalled = true;
saveCallId = '';
callStateManager.clearCallId();
resetCallState();
//showCustomSnackBar("Something went wrong",isError: true);
});
}
} catch (exception, stackTrace) {
_isApiCalled = true;
saveCallId = '';
callStateManager.clearCallId();
resetCallState();
debugPrint("CATCH Error");
await Sentry.captureException(exception, stackTrace: stackTrace);
//showCustomSnackBar("Something went wrong",isError: true);
setState(() {});
}
}
I have tried these :-
- Verified logs in Console.app (No CallKit logs appear in TestFlight).
- Checked that CallKit.framework is linked but not embedded.
- Confirmed that App ID has VoIP and Background Modes enabled in the Apple Developer Portal.
- Tried using UIApplication.shared.beginBackgroundTask to keep the app alive during a call.
- These "Setting up call monitoring", "Call state reset completed - isApiCalled: $_isApiCalled" and all these lines print("Entering _handleCallEnded"); print("Current state - isApiCalled: $_isApiCalled, mounted: $mounted"); print("Duration to process: $durationInSeconds seconds"); but durationInSeconds has 0 value in it in mixing file code lines are printing in console.app logs
Question:
- Why does CallKit stop working in the Release/TestFlight build but works fine in Debug?
- How can I ensure that CXCallObserver detects calls in a TestFlight build?
- Is there an additional entitlement or configuration required for CallKit to work in release mode?
I am working on an iOS app using Flutter that tracks outgoing calls using CallKit. The call tracking functionality works perfectly in Debug mode but does not work when the app is published to TestFlight.
I have already added Background Modes (voip, audio, processing, fetch) in Info.plist. I have added CallKit.framework in Xcode under Link Binary With Libraries (set to Optional). I have also added the required entitlements in Runner.entitlements:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>aps-environment</key>
<string>production</string>
</dict>
</plist>
These are the necessary permission which I used in info.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.agent.mygenie</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>MyGenie</string>
<key>CFBundleDocumentTypes</key>
<array/>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>mygenie</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCallKitUsageDescription</key>
<string>This app needs access to CallKit for call handling</string>
<key>NSContactsUsageDescription</key>
<string>This app needs access to your contacts for calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app needs access to microphone for calls</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>This app needs access to photo library for profile picture updation</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>voip</string>
<string>processing</string>
<string>fetch</string>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
This is the app delegate.swift file code :-
import Flutter
import UIKit
import CallKit
import AVFoundation
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
// MARK: - Properties
private var callObserver: CXCallObserver?
private var callStartTime: Date?
private var flutterChannel: FlutterMethodChannel?
private var isCallActive = false
private var currentCallDuration: Int = 0
private var callTimer: Timer?
private var lastKnownDuration: Int = 0
private var isOutgoingCall = false
// MARK: - Application Lifecycle
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Ensure window and root view controller are properly set up
guard let controller = window?.rootViewController as? FlutterViewController else {
print("Failed to get FlutterViewController")
return false
}
// Setup Flutter plugins
do {
try GeneratedPluginRegistrant.register(with: self)
} catch {
print("Failed to register Flutter plugins: \(error)")
return false
}
// Setup method channel
setupMethodChannel(controller: controller)
// Setup call observer
setupCallObserver()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
// MARK: - Private Methods
private func setupMethodChannel(controller: FlutterViewController) {
flutterChannel = FlutterMethodChannel(
name: "callkit_channel",
binaryMessenger: controller.binaryMessenger
)
flutterChannel?.setMethodCallHandler { [weak self] (call, result) in
self?.handleMethodCall(call, result: result)
}
}
private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "checkCallStatus":
result([
"isActive": isCallActive,
"duration": currentCallDuration,
"isOutgoing": isOutgoingCall
])
case "getCurrentDuration":
result(currentCallDuration)
case "requestPermissions":
requestPermissions(result: result)
case "initiateOutgoingCall":
isOutgoingCall = true
result(true)
default:
result(FlutterMethodNotImplemented)
}
}
private func setupCallObserver() {
print("Inside the call observer setup")
#if DEBUG
callObserver = CXCallObserver()
callObserver?.setDelegate(self, queue: .main)
print("Call Kit functionality is enabled for this fake environment")
#else
// Check if the app is running in a release environment
if Bundle.main.bundleIdentifier == "com.agent.mygenie" {
callObserver = CXCallObserver()
callObserver?.setDelegate(self, queue: .main)
print("Call Kit functionality is enabled for this prod environment")
} else {
print("Call Kit functionality is not enabled for this environment")
}
#endif
// callObserver = CXCallObserver()
// callObserver?.setDelegate(self, queue: .main)
}
private func startCallTimer() {
guard isOutgoingCall else { return }
print("Starting call timer for outgoing call")
callTimer?.invalidate()
currentCallDuration = 0
callStartTime = Date()
lastKnownDuration = 0
callTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateCallDuration()
}
}
private func updateCallDuration() {
guard let startTime = callStartTime else { return }
currentCallDuration = Int(Date().timeIntervalSince(startTime))
lastKnownDuration = currentCallDuration
print("Current duration: \(currentCallDuration)")
flutterChannel?.invokeMethod("onCallDurationUpdate", arguments: [
"duration": currentCallDuration,
"isOutgoing": true
])
}
private func stopCallTimer() {
guard isOutgoingCall else { return }
print("Stopping call timer")
callTimer?.invalidate()
callTimer = nil
if let startTime = callStartTime {
let finalDuration = Int(Date().timeIntervalSince(startTime))
currentCallDuration = max(finalDuration, lastKnownDuration)
print("Final duration calculated: \(currentCallDuration)")
} else {
currentCallDuration = lastKnownDuration
print("Using last known duration: \(lastKnownDuration)")
}
}
private func requestPermissions(result: @escaping FlutterResult) {
AVAudioSession.sharedInstance().requestRecordPermission { granted in
DispatchQueue.main.async {
print("Microphone permission granted: \(granted)")
result(granted)
}
}
}
private func resetCallState() {
isCallActive = false
isOutgoingCall = false
currentCallDuration = 0
lastKnownDuration = 0
callStartTime = nil
callTimer?.invalidate()
callTimer = nil
}
}
// MARK: - CXCallObserverDelegate
extension AppDelegate: CXCallObserverDelegate {
func callObserver(_ callObserver: CXCallObserver, callChanged call: CXCall) {
// Update outgoing call status if needed
if !isOutgoingCall {
isOutgoingCall = call.isOutgoing
}
// Only process outgoing calls
guard isOutgoingCall else {
print("Ignoring incoming call")
return
}
handleCallStateChange(call)
}
private func handleCallStateChange(_ call: CXCall) {
if call.hasConnected && isOutgoingCall {
handleCallConnected()
}
if call.hasEnded && isOutgoingCall {
handleCallEnded()
}
}
private func handleCallConnected() {
print("Outgoing call connected")
isCallActive = true
startCallTimer()
flutterChannel?.invokeMethod("onCallStarted", arguments: [
"isOutgoing": true
])
}
private func handleCallEnded() {
print("Outgoing call ended")
isCallActive = false
stopCallTimer()
let finalDuration = max(currentCallDuration, lastKnownDuration)
print("Sending final duration: \(finalDuration)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.sendCallEndedEvent(duration: finalDuration)
}
}
private func sendCallEndedEvent(duration: Int) {
flutterChannel?.invokeMethod("onCallEnded", arguments: [
"duration": duration,
"isOutgoing": true
])
resetCallState()
}
}
// MARK: - CXCall Extension
extension CXCall {
var isOutgoing: Bool {
return hasConnected && !hasEnded
}
}
and this is how I setup it in flutter using method channel in a one mixing file to attach that file on a screen where I needed it :-
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter/material.dart';
import 'dart:io';
import 'package:get/get.dart';
import 'package:MyGenie/call_state.dart';
mixin CallTrackingMixin<T extends StatefulWidget> on State<T> {
final CallStateManager callStateManager = CallStateManager();
static const MethodChannel platform = MethodChannel('callkit_channel');
Timer? _callDurationTimer;
bool _isCallActive = false;
int _currentCallDuration = 0;
int _callTimeDuration = 0;
DateTime? _callStartTime;
StreamController<int>? _durationController;
int _lastKnownDuration = 0;
bool _isApiCalled = false;
@override
void initState() {
super.initState();
print("InitState - Setting up call monitoring");
_setupCallMonitoring();
print("Call monitoring setup completed");
}
@override
void dispose() {
_durationController?.close();
super.dispose();
}
Future<void> _setupCallMonitoring() async {
print("Setting up call monitoring");
_durationController?.close();
_durationController = StreamController<int>.broadcast();
platform.setMethodCallHandler((MethodCall call) async {
print("Method call received: ${call.method}");
if (!mounted) {
print("Widget not mounted, returning");
return;
}
switch (call.method) {
case 'onCallStarted':
print("Call started - Resetting states");
setState(() {
_isCallActive = true;
_callStartTime = DateTime.now();
_isApiCalled = false; // Reset here explicitly
});
print("Call states reset - isApiCalled: $_isApiCalled");
break;
case 'onCallEnded':
print("Call ended event received");
print("Current isApiCalled status: $_isApiCalled");
if (call.arguments != null) {
final Map<dynamic, dynamic> args = call.arguments;
final int duration = args['duration'] as int;
print("Processing call end with duration: $_callTimeDuration");
// Force reset isApiCalled here
setState(() {
_isApiCalled = false;
});
await _handleCallEnded(_currentCallDuration);
}
setState(() {
_isCallActive = false;
});
break;
case 'onCallDurationUpdate':
if (call.arguments != null && mounted) {
final Map<dynamic, dynamic> args = call.arguments;
final int duration = args['duration'] as int;
setState(() {
_currentCallDuration = duration;
_lastKnownDuration = duration;
_callTimeDuration = duration;
});
_durationController?.add(duration);
print("Duration update: $duration seconds");
}
break;
}
});
}
void resetCallState() {
print("Resetting call state");
setState(() {
_isApiCalled = false;
_isCallActive = false;
_currentCallDuration = 0;
_lastKnownDuration = 0;
_callTimeDuration = 0;
_callStartTime = null;
});
print("Call state reset completed - isApiCalled: $_isApiCalled");
}
Future<void> _handleCallEnded(int durationInSeconds) async {
print("Entering _handleCallEnded");
print("Current state - isApiCalled: $_isApiCalled, mounted: $mounted");
print("Duration to process: $durationInSeconds seconds");
// Force check and reset if needed
if (_isApiCalled) {
print("Resetting isApiCalled flag as it was true");
setState(() {
_isApiCalled = false;
});
}
if (mounted) {
final duration = Duration(seconds: durationInSeconds);
final formattedDuration = _formatDuration(duration);
print("Processing call end with duration: $formattedDuration");
if (durationInSeconds == 0 && _callStartTime != null) {
final fallbackDuration = DateTime.now().difference(_callStartTime!);
final fallbackSeconds = fallbackDuration.inSeconds;
print("Using fallback duration: $fallbackSeconds seconds");
await _saveCallDuration(fallbackSeconds);
} else {
print("Using provided duration: $durationInSeconds seconds");
await _saveCallDuration(durationInSeconds);
}
setState(() {
_isApiCalled = true;
});
print("Call processing completed - isApiCalled set to true");
} else {
print("Widget not mounted, skipping call processing");
}
}
Future<void> _saveCallDuration(int durationInSeconds) async {
if (durationInSeconds > 0) {
final formattedDuration =
_formatDuration(Duration(seconds: durationInSeconds));
if (callStateManager.callId.isNotEmpty) {
saveRandomCallDuration(formattedDuration);
}
if (callStateManager.leadCallId.isNotEmpty) {
saveCallDuration(formattedDuration);
}
} else {
print("Warning: Attempting to save zero duration");
}
}
void saveCallDuration(String duration);
void saveRandomCallDuration(String duration);
String _formatDuration(Duration duration) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
String hours =
duration.inHours > 0 ? '${twoDigits(duration.inHours)}:' : '';
String minutes = twoDigits(duration.inMinutes.remainder(60));
String seconds = twoDigits(duration.inSeconds.remainder(60));
return '$hours$minutes:$seconds';
}
void resetCallTracking() {
_setupCallMonitoring();
}
}
And this is the main_call.dart file code where I'm saving call duration to the database with api :-
@override
Future<void> saveRandomCallDuration(String duration) async {
await Sentry.captureMessage("save random call Duration :- ${duration} against this id :- ${callStateManager.callId}");
print(
"save random call Duration :- ${duration} against this id :- ${callStateManager.callId}");
try {
String token = await SharedPreferencesHelper.getFcmToken();
String apiUrl = ApiUrls.saveRandomCallDuration;
final response = await http.post(
Uri.parse(apiUrl),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'Authorization': 'Bearer $token',
},
body: jsonEncode(<String, String>{
"id": callStateManager.callId,
"call_duration": duration
//default : lead call ; filters : random call
}),
);
if (response.statusCode == 200) {
_isApiCalled = true;
saveCallId = '';
callStateManager.clearCallId();
resetCallState();
setState(() {});
} else {
setState(() {
_isApiCalled = true;
saveCallId = '';
callStateManager.clearCallId();
resetCallState();
//showCustomSnackBar("Something went wrong",isError: true);
});
}
} catch (exception, stackTrace) {
_isApiCalled = true;
saveCallId = '';
callStateManager.clearCallId();
resetCallState();
debugPrint("CATCH Error");
await Sentry.captureException(exception, stackTrace: stackTrace);
//showCustomSnackBar("Something went wrong",isError: true);
setState(() {});
}
}
I have tried these :-
- Verified logs in Console.app (No CallKit logs appear in TestFlight).
- Checked that CallKit.framework is linked but not embedded.
- Confirmed that App ID has VoIP and Background Modes enabled in the Apple Developer Portal.
- Tried using UIApplication.shared.beginBackgroundTask to keep the app alive during a call.
- These "Setting up call monitoring", "Call state reset completed - isApiCalled: $_isApiCalled" and all these lines print("Entering _handleCallEnded"); print("Current state - isApiCalled: $_isApiCalled, mounted: $mounted"); print("Duration to process: $durationInSeconds seconds"); but durationInSeconds has 0 value in it in mixing file code lines are printing in console.app logs
Question:
- Why does CallKit stop working in the Release/TestFlight build but works fine in Debug?
- How can I ensure that CXCallObserver detects calls in a TestFlight build?
- Is there an additional entitlement or configuration required for CallKit to work in release mode?
1 Answer
Reset to default 0You can only monitor call activity in a release build while your app is the current foreground app. You can't monitor call activity while your app is in the background.
Debug apps that are run under Xcode receive unlimited background time and are not suspended, which is why it seems to work for you in that case.
A release mode app, whether from the App Store or TestFlight, or even a debug build that is launched directly on the phone does not receive unlimited background time and will be suspended when you switch to another app or the Home Screen.
tel
urls opened from your app itself) – Paulw11 Commented Mar 26 at 8:24