I’m engaged on an iOS app utilizing Flutter that tracks outgoing calls utilizing CallKit. The decision monitoring performance works completely in Debug mode however doesn’t work when the app is printed to TestFlight.
I’ve already added Background Modes (voip, audio, processing, fetch) in Information.plist.
I’ve added CallKit.framework in Xcode beneath Hyperlink Binary With Libraries (set to Elective).
I’ve additionally added the required entitlements in Runner.entitlements:
aps-environment
manufacturing
These are the required permission which I utilized in information.plist:
BGTaskSchedulerPermittedIdentifiers
com.agent.mygenie
CADisableMinimumFrameDurationOnPhone
CFBundleDevelopmentRegion
$(DEVELOPMENT_LANGUAGE)
CFBundleDisplayName
MyGenie
CFBundleDocumentTypes
CFBundleExecutable
$(EXECUTABLE_NAME)
CFBundleIdentifier
$(PRODUCT_BUNDLE_IDENTIFIER)
CFBundleInfoDictionaryVersion
6.0
CFBundleName
mygenie
CFBundlePackageType
APPL
CFBundleShortVersionString
$(FLUTTER_BUILD_NAME)
CFBundleSignature
????
CFBundleVersion
$(FLUTTER_BUILD_NUMBER)
LSRequiresIPhoneOS
NSCallKitUsageDescription
This app wants entry to CallKit for name dealing with
NSContactsUsageDescription
This app wants entry to your contacts for calls
NSMicrophoneUsageDescription
This app wants entry to microphone for calls
NSPhotoLibraryUsageDescription
This app wants entry to picture library for profile image updation
UIApplicationSupportsIndirectInputEvents
UIBackgroundModes
voip
processing
fetch
audio
UILaunchStoryboardName
LaunchScreen
UIMainStoryboardFile
Foremost
UISupportedInterfaceOrientations
UIInterfaceOrientationPortrait
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad
UIInterfaceOrientationPortrait
UIInterfaceOrientationPortraitUpsideDown
UIInterfaceOrientationLandscapeLeft
UIInterfaceOrientationLandscapeRight
That is the app delegate.swift file code :-
import Flutter
import UIKit
import CallKit
import AVFoundation
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
// MARK: - Properties
personal var callObserver: CXCallObserver?
personal var callStartTime: Date?
personal var flutterChannel: FlutterMethodChannel?
personal var isCallActive = false
personal var currentCallDuration: Int = 0
personal var callTimer: Timer?
personal var lastKnownDuration: Int = 0
personal var isOutgoingCall = false
// MARK: - Software Lifecycle
override func utility(
_ utility: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Guarantee window and root view controller are correctly arrange
guard let controller = window?.rootViewController as? FlutterViewController else {
print("Didn't get FlutterViewController")
return false
}
// Setup Flutter plugins
do {
attempt GeneratedPluginRegistrant.register(with: self)
} catch {
print("Didn't register Flutter plugins: (error)")
return false
}
// Setup technique channel
setupMethodChannel(controller: controller)
// Setup name observer
setupCallObserver()
return tremendous.utility(utility, didFinishLaunchingWithOptions: launchOptions)
}
// MARK: - Non-public Strategies
personal func setupMethodChannel(controller: FlutterViewController) {
flutterChannel = FlutterMethodChannel(
title: "callkit_channel",
binaryMessenger: controller.binaryMessenger
)
flutterChannel?.setMethodCallHandler { [weak self] (name, outcome) in
self?.handleMethodCall(name, outcome: outcome)
}
}
personal func handleMethodCall(_ name: FlutterMethodCall, outcome: @escaping FlutterResult) {
swap name.technique {
case "checkCallStatus":
outcome([
"isActive": isCallActive,
"duration": currentCallDuration,
"isOutgoing": isOutgoingCall
])
case "getCurrentDuration":
outcome(currentCallDuration)
case "requestPermissions":
requestPermissions(outcome: outcome)
case "initiateOutgoingCall":
isOutgoingCall = true
outcome(true)
default:
outcome(FlutterMethodNotImplemented)
}
}
personal func setupCallObserver() {
print("Inside the decision observer setup")
#if DEBUG
callObserver = CXCallObserver()
callObserver?.setDelegate(self, queue: .essential)
print("Name Equipment performance is enabled for this faux atmosphere")
#else
// Verify if the app is working in a launch atmosphere
if Bundle.essential.bundleIdentifier == "com.agent.mygenie" {
callObserver = CXCallObserver()
callObserver?.setDelegate(self, queue: .essential)
print("Name Equipment performance is enabled for this prod atmosphere")
} else {
print("Name Equipment performance will not be enabled for this atmosphere")
}
#endif
// callObserver = CXCallObserver()
// callObserver?.setDelegate(self, queue: .essential)
}
personal func startCallTimer() {
guard isOutgoingCall else { return }
print("Beginning name timer for outgoing name")
callTimer?.invalidate()
currentCallDuration = 0
callStartTime = Date()
lastKnownDuration = 0
callTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.updateCallDuration()
}
}
personal func updateCallDuration() {
guard let startTime = callStartTime else { return }
currentCallDuration = Int(Date().timeIntervalSince(startTime))
lastKnownDuration = currentCallDuration
print("Present length: (currentCallDuration)")
flutterChannel?.invokeMethod("onCallDurationUpdate", arguments: [
"duration": currentCallDuration,
"isOutgoing": true
])
}
personal func stopCallTimer() {
guard isOutgoingCall else { return }
print("Stopping name timer")
callTimer?.invalidate()
callTimer = nil
if let startTime = callStartTime {
let finalDuration = Int(Date().timeIntervalSince(startTime))
currentCallDuration = max(finalDuration, lastKnownDuration)
print("Remaining length calculated: (currentCallDuration)")
} else {
currentCallDuration = lastKnownDuration
print("Utilizing final recognized length: (lastKnownDuration)")
}
}
personal func requestPermissions(outcome: @escaping FlutterResult) {
AVAudioSession.sharedInstance().requestRecordPermission { granted in
DispatchQueue.essential.async {
print("Microphone permission granted: (granted)")
outcome(granted)
}
}
}
personal 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 name: CXCall) {
// Replace outgoing name standing if wanted
if !isOutgoingCall {
isOutgoingCall = name.isOutgoing
}
// Solely course of outgoing calls
guard isOutgoingCall else {
print("Ignoring incoming name")
return
}
handleCallStateChange(name)
}
personal func handleCallStateChange(_ name: CXCall) {
if name.hasConnected && isOutgoingCall {
handleCallConnected()
}
if name.hasEnded && isOutgoingCall {
handleCallEnded()
}
}
personal func handleCallConnected() {
print("Outgoing name related")
isCallActive = true
startCallTimer()
flutterChannel?.invokeMethod("onCallStarted", arguments: [
"isOutgoing": true
])
}
personal func handleCallEnded() {
print("Outgoing name ended")
isCallActive = false
stopCallTimer()
let finalDuration = max(currentCallDuration, lastKnownDuration)
print("Sending closing length: (finalDuration)")
DispatchQueue.essential.asyncAfter(deadline: .now() + 0.5) { [weak self] in
self?.sendCallEndedEvent(length: finalDuration)
}
}
personal func sendCallEndedEvent(length: Int) {
flutterChannel?.invokeMethod("onCallEnded", arguments: [
"duration": duration,
"isOutgoing": true
])
resetCallState()
}
}
// MARK: - CXCall Extension
extension CXCall {
var isOutgoing: Bool {
return hasConnected && !hasEnded
}
}
and that is how I setup it in flutter utilizing technique channel in a one mixing file to connect that file on a display the place I wanted it :-
import 'dart:async';
import 'bundle:flutter/providers.dart';
import 'bundle:flutter/materials.dart';
import 'dart:io';
import 'bundle:get/get.dart';
import 'bundle:MyGenie/call_state.dart';
mixin CallTrackingMixin on State {
closing CallStateManager callStateManager = CallStateManager();
static const MethodChannel platform = MethodChannel('callkit_channel');
Timer? _callDurationTimer;
bool _isCallActive = false;
int _currentCallDuration = 0;
int _callTimeDuration = 0;
DateTime? _callStartTime;
StreamController? _durationController;
int _lastKnownDuration = 0;
bool _isApiCalled = false;
@override
void initState() {
tremendous.initState();
print("InitState - Organising name monitoring");
_setupCallMonitoring();
print("Name monitoring setup accomplished");
}
@override
void dispose() {
_durationController?.shut();
tremendous.dispose();
}
Future _setupCallMonitoring() async {
print("Organising name monitoring");
_durationController?.shut();
_durationController = StreamController.broadcast();
platform.setMethodCallHandler((MethodCall name) async {
print("Technique name acquired: ${name.technique}");
if (!mounted) {
print("Widget not mounted, returning");
return;
}
swap (name.technique) {
case 'onCallStarted':
print("Name began - Resetting states");
setState(() {
_isCallActive = true;
_callStartTime = DateTime.now();
_isApiCalled = false; // Reset right here explicitly
});
print("Name states reset - isApiCalled: $_isApiCalled");
break;
case 'onCallEnded':
print("Name ended occasion acquired");
print("Present isApiCalled standing: $_isApiCalled");
if (name.arguments != null) {
closing Map args = name.arguments;
closing int length = args['duration'] as int;
print("Processing name finish with length: $_callTimeDuration");
// Power reset isApiCalled right here
setState(() {
_isApiCalled = false;
});
await _handleCallEnded(_currentCallDuration);
}
setState(() {
_isCallActive = false;
});
break;
case 'onCallDurationUpdate':
if (name.arguments != null && mounted) {
closing Map args = name.arguments;
closing int length = args['duration'] as int;
setState(() {
_currentCallDuration = length;
_lastKnownDuration = length;
_callTimeDuration = length;
});
_durationController?.add(length);
print("Length replace: $length seconds");
}
break;
}
});
}
void resetCallState() {
print("Resetting name state");
setState(() {
_isApiCalled = false;
_isCallActive = false;
_currentCallDuration = 0;
_lastKnownDuration = 0;
_callTimeDuration = 0;
_callStartTime = null;
});
print("Name state reset accomplished - isApiCalled: $_isApiCalled");
}
Future _handleCallEnded(int durationInSeconds) async {
print("Getting into _handleCallEnded");
print("Present state - isApiCalled: $_isApiCalled, mounted: $mounted");
print("Length to course of: $durationInSeconds seconds");
// Power verify and reset if wanted
if (_isApiCalled) {
print("Resetting isApiCalled flag because it was true");
setState(() {
_isApiCalled = false;
});
}
if (mounted) {
closing length = Length(seconds: durationInSeconds);
closing formattedDuration = _formatDuration(length);
print("Processing name finish with length: $formattedDuration");
if (durationInSeconds == 0 && _callStartTime != null) {
closing fallbackDuration = DateTime.now().distinction(_callStartTime!);
closing fallbackSeconds = fallbackDuration.inSeconds;
print("Utilizing fallback length: $fallbackSeconds seconds");
await _saveCallDuration(fallbackSeconds);
} else {
print("Utilizing supplied length: $durationInSeconds seconds");
await _saveCallDuration(durationInSeconds);
}
setState(() {
_isApiCalled = true;
});
print("Name processing accomplished - isApiCalled set to true");
} else {
print("Widget not mounted, skipping name processing");
}
}
Future _saveCallDuration(int durationInSeconds) async {
if (durationInSeconds > 0) {
closing formattedDuration =
_formatDuration(Length(seconds: durationInSeconds));
if (callStateManager.callId.isNotEmpty) {
saveRandomCallDuration(formattedDuration);
}
if (callStateManager.leadCallId.isNotEmpty) {
saveCallDuration(formattedDuration);
}
} else {
print("Warning: Trying to avoid wasting zero length");
}
}
void saveCallDuration(String length);
void saveRandomCallDuration(String length);
String _formatDuration(Length length) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
String hours =
length.inHours > 0 ? '${twoDigits(length.inHours)}:' : '';
String minutes = twoDigits(length.inMinutes.the rest(60));
String seconds = twoDigits(length.inSeconds.the rest(60));
return '$hours$minutes:$seconds';
}
void resetCallTracking() {
_setupCallMonitoring();
}
}
And that is the main_call.dart file code the place I am saving name length to the database with api :-
@override
Future saveRandomCallDuration(String length) async {
await Sentry.captureMessage("save random name Length :- ${length} towards this id :- ${callStateManager.callId}");
print(
"save random name Length :- ${length} towards this id :- ${callStateManager.callId}");
attempt {
String token = await SharedPreferencesHelper.getFcmToken();
String apiUrl = ApiUrls.saveRandomCallDuration;
closing response = await http.publish(
Uri.parse(apiUrl),
headers: {
'Content material-Sort': 'utility/json',
'Settle for': 'utility/json',
'Authorization': 'Bearer $token',
},
physique: jsonEncode({
"id": callStateManager.callId,
"call_duration": length
//default : lead name ; filters : random name
}),
);
if (response.statusCode == 200) {
_isApiCalled = true;
saveCallId = '';
callStateManager.clearCallId();
resetCallState();
setState(() {});
} else {
setState(() {
_isApiCalled = true;
saveCallId = '';
callStateManager.clearCallId();
resetCallState();
//showCustomSnackBar("One thing went flawed",isError: true);
});
}
} catch (exception, stackTrace) {
_isApiCalled = true;
saveCallId = '';
callStateManager.clearCallId();
resetCallState();
debugPrint("CATCH Error");
await Sentry.captureException(exception, stackTrace: stackTrace);
//showCustomSnackBar("One thing went flawed",isError: true);
setState(() {});
}
}
- Verified logs in Console.app (No CallKit logs seem in TestFlight).
- Checked that CallKit.framework is linked however not embedded.
- Confirmed that App ID has VoIP and Background Modes enabled within the Apple Developer Portal.
- Tried utilizing UIApplication.shared.beginBackgroundTask to maintain the app alive throughout a name.
- These “Organising name monitoring”, “Name state reset accomplished – isApiCalled: $_isApiCalled” and all these strains print(“Getting into _handleCallEnded”);
print(“Present state – isApiCalled: $_isApiCalled, mounted: $mounted”);
print(“Length to course of: $durationInSeconds seconds”); however durationInSeconds has 0 worth in it in mixing file code strains are printing in console.app logs
- Why does CallKit cease working within the Launch/TestFlight construct however works superb in Debug?
- How can I make sure that CXCallObserver detects calls in a TestFlight construct?
- Is there an extra entitlement or configuration required for CallKit to work in launch mode?