LiveCommunicationKit: report Incoming but Assertion failure in -[PKPushRegistry _terminateAppIfThereAreUnhandledVoIPPushes]

I completed the CallKit Demo with the same code.

When I changed to LiveCommunicationKit, the code goes perfectly when the app is in foreground, but it crashed in background. If I changed the reportIncoming method from LCK to CallKit, it goes well. What is the reason?

I changed the method from

func pushRegistry(_ registry: PKPushRegistry,
                      didReceiveIncomingPushWith payload: PKPushPayload,
                      for type: PKPushType, completion: @escaping () -> Void)

to

func pushRegistry(_ registry: PKPushRegistry,
                      didReceiveIncomingPushWith payload: PKPushPayload,
                      for type: PKPushType) async

it crashed before show the print "receive voip noti".

Here is the core code:

var providerDelegate: ProviderDelegate?

func pushRegistry(_ registry: PKPushRegistry,
                      didReceiveIncomingPushWith payload: PKPushPayload,
                      for type: PKPushType, completion: @escaping () -> Void) {
        if type != .voIP { return }
        guard let uuidString = payload.dictionaryPayload["uuid"] as? String,
              let uuid = UUID(uuidString: uuidString),
              let handle = payload.dictionaryPayload["handle"] as? String,
              let hasVideo = payload.dictionaryPayload["hasVideo"] as? Bool,
              let callerID = payload.dictionaryPayload["callerID"] as? String else {
            return
        }
 
        print("receive voip noti: \(type):\(payload.dictionaryPayload)")

        if #available(iOS 17.4, *) {
           // This code is only goes perfectly when the App is in foreground
            var update = Conversation.Update(members: [Handle(type: .generic, value: callerID, displayName: callerID)])
            if hasVideo {
                update.capabilities = [.video, .playingTones]
            } else {
                update.capabilities = .playingTones
            }

            Task { @MainActor in
                do {
                    print("LCKit report start")
                    try await LCKitManager.shared.reportNewIncomingConversation(uuid: uuid, update: update)
                    print("LCKit report success")
                    completion()
                } catch {
                    print("LCKit report failed")
                    print(error)
                    completion()
                }
            }
        } else {
            // It went perfectly 
            providerDelegate?.reportIncomingCall(uuid: uuid, callerID: callerID, handle: handle, hasVideo: hasVideo) { _ in
            completion()
        }
    }

@available(iOS 17.4, *)
final class LCKitManager {
    static let shared = LCKitManager()
    let manager: ConversationManager

    init() {
        manager = ConversationManager(configuration: type(of: self).configuration)
        manager.delegate = self
    }

    static var configuration: ConversationManager.Configuration {
        ConversationManager.Configuration(ringtoneName: "Ringtone.aif",
                                          iconTemplateImageData: #imageLiteral(resourceName: "IconMask").pngData(),
                                          maximumConversationGroups: 1,
                                          maximumConversationsPerConversationGroup: 1,
                                          includesConversationInRecents: true,
                                          supportsVideo: false,
                                          supportedHandleTypes: [.generic])
    }
    
    func reportNewIncomingConversation(uuid: UUID, update: Conversation.Update) async throws {
        try await manager.reportNewIncomingConversation(uuid: uuid, update: update)
    }
}

final class ProviderDelegate: NSObject, ObservableObject {
    static let providerConfiguration: CXProviderConfiguration = {
        let providerConfiguration: CXProviderConfiguration
        if #available(iOS 14.0, *) {
            providerConfiguration = CXProviderConfiguration()
        } else {
            providerConfiguration = CXProviderConfiguration(localizedName: "Name")
        }
        providerConfiguration.supportsVideo = false
        providerConfiguration.maximumCallGroups = 1
        providerConfiguration.maximumCallsPerCallGroup = 1
        let iconMaskImage = #imageLiteral(resourceName: "IconMask")
        providerConfiguration.iconTemplateImageData = iconMaskImage.pngData()
        providerConfiguration.ringtoneSound = "Ringtone.aif"
        providerConfiguration.includesCallsInRecents = true
        providerConfiguration.supportedHandleTypes = [.generic]
        return providerConfiguration
    }()

    private let provider: CXProvider

    init( {
        provider = CXProvider(configuration: type(of: self).providerConfiguration)
        super.init()
        provider.setDelegate(self, queue: nil)
    }

    func reportCall(uuid: UUID, callerID: String, handle: String, hasVideo: Bool, completion: ((Error?) -> Void)? = nil) {
        let callerUUID = UUID()
        let update = CXCallUpdate()
        update.remoteHandle = CXHandle(type: .generic, value: callerID)
        update.hasVideo = hasVideo
        update.localizedCallerName = callerID
        // Report the incoming call to the system
        provider.reportNewIncomingCall(with: callerUUID, update: update) { [weak self] error in
            completion?(error)
        }
    }
}
Answered by DTS Engineer in 826593022

Expanding on what I said here:

The API contract fo PKPushRegistry specifically requires that you report a call before returning. Trying to bounce to the main thread like this means that you did NOT in fact do that.

The issue I was describing here wasn't about the specific thread, it was about separating the execution stream. In concrete term, using "Task" means "do this later" and you cannot do that here. Here is the code modified to remove Task:

    @objc func startConversation(targetHandleValue : String,uuid : String, completion: @escaping (Bool, Error?) -> Void ) async {
....
        let incomingCallerHandle = Handle(type: .generic, value: targetHandleValue ,displayName: targetHandleValue)
            do {
                try await conversationManager.reportNewIncomingConversation(uuid: conversationUUID, update: update)
                completion(true, nil)
            } catch {
                print("An error occurred: \(error.localizedDescription)")
                completion(false, error)
            }
        }

Covering a few other details:

In fact, the same processing logic works correctly in the background when using CallKit.

I'd be curious to see what your CallKit code looked like, but the "Task" usage above will crash CallKit in EXACTLY the same way LiveCommunicationKit is crashing.

When I switch to LiveCommunicationKit, It also displays normally when the application in the foreground, this issue only occurs in the background.

Yes, this is expected behavior in both CallKit and LiveCommunicationKit. App are NOT required to report calls if:

  1. They are in the foreground.

  2. They already have an active call.

Again, that behavior is exactly the same between CallKit and LiveCommunicationKit.

And the console only prints "Received push" without "LCKit Start Answer"

Yes, because that's what your code actually "does". Note that if you modified your code to print like this:

...
   print("Received push")

    // !!!!!!! 
    // Whatever I write `@MainActor in` or remove this code,  Most of the time it's wrong when the app is in background
    Task {
        print("LCKit Start Answer")
....
    }
	print("After Task")
....

What will print in the foreground is:

Received push
After Task
LCKit Start Answer

And what will happen in the background is:

Received push
After Task
<App Crash>

...because you returned from didRecieveIncomingPush BEFORE you called reportNewIncomingConversation.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

I init the pushRegistry with main queue and use the method "func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) async", it crash immediately before the print "receive voip noti".

if I init the pushRegistry with a custom queue, it will show the print "receive voip noti", and crashed after the print "LCKit report start"

let pushRegistry = PKPushRegistry(queue: DispatchQueue(label: "LCKit", qos: .userInteractive))

Assertion Stack:


Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Killing app because it never posted an incoming call to the system after receiving a PushKit VoIP push.' *** First throw call stack: (0x1a6774f20 0x19e5f32b8 0x1a5c7b7f8 0x2191b09f8 0x1ae61add4 0x1ae62a2c4 0x2191afc60 0x1ae61913c 0x1ae61add4 0x1ae6295a4 0x1ae6291b8 0x1a6747710 0x1a6744914 0x1a6743cd8 0x1eb1951a8 0x1a8d7dae8 0x1a8e31d98 0x1043927b8 0x1c9f1b154) libc++abi: terminating due to uncaught exception of type NSException


Assertion failure in -[PKPushRegistry _terminateAppIfThereAreUnhandledVoIPPushes], PKPushRegistry.m:349

When I changed to LiveCommunicationKit, the code goes perfectly when the app is in foreground, but it crashed in background. If I changed the reportIncoming method from LCK to CallKit, it goes well. What is the reason?

The issue is your attempt to transfer to the main thread here:

   Task { @MainActor in
                do {
                    print("LCKit report start")
                    try await LCKitManager.shared.reportNewIncomingConversation(uuid: uuid, update: update)
                    print("LCKit report success")
                    completion()
                } catch {
                    print("LCKit report failed")
                    print(error)
                    completion()
                }
    }

The API contract fo PKPushRegistry specifically requires that you report a call before returning. Trying to bounce to the main thread like this means that you did NOT in fact do that. You scheduled your call report call code to run on the main thread, then returned... at which point you crashed.

You can solve this in one of two ways:

  1. Set your PKPushRegistry to target the main queue and do everything there.

  2. Use the same queue for LiveCommunicationKit and call reportNewIncomingConversation directly instead of trying to change threads.

Of the two, I have a mild preferences for #1. In practice, properly written CallKit and PushKit delegates both block for minimal amounts of time and, if they are blocking for longer periods, that will cause other problems* which you'll need to fix. That means that there's very little reason not just use the main thread for both.

*I could try to be more specific, but I've never actually seen a developer fail this way.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

I'm facing the same problem. Could you please tell me how you solved it? oc

PKPushRegistry *pushRegistry = [[PKPushRegistry alloc] initWithQueue:dispatch_get_main_queue()];
pushRegistry.delegate = self;
pushRegistry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP];

  • (void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(PKPushType)type withCompletionHandler:(void(^)(void))completion{ NSLog(@"Received void push notification data"); if (type == PKPushTypeVoIP) { NSDictionary *callKit = [payload.dictionaryPayload objectForKey:@"callkit"]; NSString *handle = [callKit objectForKey:@"handle"]; NSString *uuidString = [callKit objectForKey:@"uuid"]; self.callInfo = payload.dictionaryPayload; // 发起对话 if (@available(iOS 17.4, *)) { [[LiveCommunicationWrapper shared] startConversationWithTargetHandleValue:handle uuid:uuidString completion:^(BOOL status, NSError * _Nullable error) { completion(); }]; } else { completion(); } } else { NSLog(@"Received push notification of type: %@", type); completion(); }

}

swift

@available(iOS 17.4, *) @objc class LiveCommunicationWrapper: NSObject, ConversationManagerDelegate { @objc static let shared = LiveCommunicationWrapper()

let conversationManager = ConversationManager(configuration: ConversationManager.Configuration(ringtoneName: "default_ringtone", iconTemplateImageData: nil, maximumConversationGroups: 1, maximumConversationsPerConversationGroup: 1, includesConversationInRecents: false, supportsVideo: false, supportedHandleTypes: [.generic]))

private  override init() {
    super.init()
    conversationManager.delegate = self
    print("conversationManager  init.")
}

@objc func startConversation(targetHandleValue : String,uuid : String, completion: @escaping (Bool, Error?) -> Void ){
    guard let conversationUUID = UUID(uuidString: uuid) else {
        let error = NSError(domain: "InvalidUUID", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid UUID string"])
        completion(false, error)
        return
    }
    let hasVideo = false;
    var update = Conversation.Update(members: [Handle(type: .generic, value: uuid, displayName: uuid)])
    if hasVideo {
        update.capabilities = [.video, .playingTones]
    } else {
        update.capabilities = .playingTones
    }
    let incomingCallerHandle = Handle(type: .generic, value: targetHandleValue ,displayName: targetHandleValue)
    
    Task {
        do {
            try await conversationManager.reportNewIncomingConversation(uuid: conversationUUID, update: update)
            completion(true, nil)
        } catch {
            print("An error occurred: \(error.localizedDescription)")
            completion(false, error)
        }
    }
}

In fact, the same processing logic works correctly in the background when using CallKit.

When I switch to LiveCommunicationKit, It also displays normally when the application in the foreground, this issue only occurs in the background.

And the console only prints "Received push" without "LCKit Start Answer"


let pushRegistry = PKPushRegistry(queue: .main)

if #available(iOS 17.4, *) {
    var update = Conversation.Update(members: [Handle(type: .generic, value: callerName, displayName: callerName)])
    update.capabilities = .playingTones

    print("Received push")

    // !!!!!!! 
    // Whatever I write `@MainActor in` or remove this code,  Most of the time it's wrong when the app is in background
    Task {
        print("LCKit Start Answer")
        do {
            try await LCKitManager.shared.reportNewIncomingConversation(uuid: uuid, update: update)
            print("LCKit Answer Success")
            completion()
        } catch {
            print(error)
            completion()
        }
    }
}

Console output:

Received push
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Killing app because it never posted an incoming call to the system after receiving a PushKit VoIP push.'
*** First throw call stack:
(0x1a6774f20 0x19e5f32b8 0x1a5c7b7f8 0x2191b09f8 0x1ae61add4 0x1ae62a2c4 0x2191afc60 0x1ae61913c 0x1ae61add4 0x1ae6295a4 0x1ae6291b8 0x1a6747710 0x1a6744914 0x1a6743cd8 0x1eb1951a8 0x1a8d7dae8 0x1a8e31d98 0x104faeb84 0x1c9f1b154)
libc++abi: terminating due to uncaught exception of type NSException
*** Assertion failure in -[PKPushRegistry _terminateAppIfThereAreUnhandledVoIPPushes], PKPushRegistry.m:349

After testing many times, the success rate of displaying the pop-up when the app is killed is relatively normal, with only occasional failures. However, the failure rate is very high when the app is in the background.

Moreover, when the app receives a VoIP notification and reports it while in the foreground, and then transitions to the background, the success rate of reporting the VoIP notification in the background is significantly increased.

Expanding on what I said here:

The API contract fo PKPushRegistry specifically requires that you report a call before returning. Trying to bounce to the main thread like this means that you did NOT in fact do that.

The issue I was describing here wasn't about the specific thread, it was about separating the execution stream. In concrete term, using "Task" means "do this later" and you cannot do that here. Here is the code modified to remove Task:

    @objc func startConversation(targetHandleValue : String,uuid : String, completion: @escaping (Bool, Error?) -> Void ) async {
....
        let incomingCallerHandle = Handle(type: .generic, value: targetHandleValue ,displayName: targetHandleValue)
            do {
                try await conversationManager.reportNewIncomingConversation(uuid: conversationUUID, update: update)
                completion(true, nil)
            } catch {
                print("An error occurred: \(error.localizedDescription)")
                completion(false, error)
            }
        }

Covering a few other details:

In fact, the same processing logic works correctly in the background when using CallKit.

I'd be curious to see what your CallKit code looked like, but the "Task" usage above will crash CallKit in EXACTLY the same way LiveCommunicationKit is crashing.

When I switch to LiveCommunicationKit, It also displays normally when the application in the foreground, this issue only occurs in the background.

Yes, this is expected behavior in both CallKit and LiveCommunicationKit. App are NOT required to report calls if:

  1. They are in the foreground.

  2. They already have an active call.

Again, that behavior is exactly the same between CallKit and LiveCommunicationKit.

And the console only prints "Received push" without "LCKit Start Answer"

Yes, because that's what your code actually "does". Note that if you modified your code to print like this:

...
   print("Received push")

    // !!!!!!! 
    // Whatever I write `@MainActor in` or remove this code,  Most of the time it's wrong when the app is in background
    Task {
        print("LCKit Start Answer")
....
    }
	print("After Task")
....

What will print in the foreground is:

Received push
After Task
LCKit Start Answer

And what will happen in the background is:

Received push
After Task
<App Crash>

...because you returned from didRecieveIncomingPush BEFORE you called reportNewIncomingConversation.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

LiveCommunicationKit: report Incoming but Assertion failure in -[PKPushRegistry _terminateAppIfThereAreUnhandledVoIPPushes]
 
 
Q