最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

flutter - Call Tracking with CallKit Works in Debug Mode but Not in Release (TestFlight) – iOS - Stack Overflow

programmeradmin11浏览0评论

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:

  1. Why does CallKit stop working in the Release/TestFlight build but works fine in Debug?
  2. How can I ensure that CXCallObserver detects calls in a TestFlight build?
  3. 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:

  1. Why does CallKit stop working in the Release/TestFlight build but works fine in Debug?
  2. How can I ensure that CXCallObserver detects calls in a TestFlight build?
  3. Is there an additional entitlement or configuration required for CallKit to work in release mode?
Share Improve this question asked Mar 26 at 6:10 Beast69Beast69 111 silver badge4 bronze badges 1
  • You can't monitor all outgoing calls in a released app. Debug builds run from Xcode get unlimited background execution time. This is not the case for release builds. You will only receive events for outgoing calls made while your app is the active app (ie tel urls opened from your app itself) – Paulw11 Commented Mar 26 at 8:24
Add a comment  | 

1 Answer 1

Reset to default 0

You 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.

发布评论

评论列表(0)

  1. 暂无评论