flutter – Name Monitoring with CallKit Works in Debug Mode however Not in Launch (TestFlight) – iOS

0
7
flutter – Name Monitoring with CallKit Works in Debug Mode however Not in Launch (TestFlight) – iOS


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
  1. Why does CallKit cease working within the Launch/TestFlight construct however works superb in Debug?
  2. How can I make sure that CXCallObserver detects calls in a TestFlight construct?
  3. Is there an extra entitlement or configuration required for CallKit to work in launch mode?

LEAVE A REPLY

Please enter your comment!
Please enter your name here