Skip to content

Engineering10 min read

Three iOS rules for calling apps: VoIP pushes, CallKit and AVAudioSession

Skip the CallKit report for a VoIP push and iOS kills your app. Get the audio session wrong and the call connects in silence. The three rules behind ID Caller's iOS call layer, and the silent calls that taught us them.

ID Caller is our app for private audio and video calls without a phone number (project page). Almost all of the client is shared Kotlin with a Compose Multiplatform UI. The iOS module is a thin adapter to PushKit, CallKit and the audio session. It is a small amount of code, but it is where iOS enforces its strictest rules for calling apps. If you break the first rule, the system terminates your app and can stop delivering VoIP pushes to it. If you break the other two, the call connects but nobody hears anything.

We keep these rules written down: the first one is invariant 5 in the project's agent rules, the other two are lessons from field tests. For each rule we explain what Apple requires and why, what broke for us, and what the code looks like now.

Where the iOS call layer lives

All call logic (the 1:1 state machine, the group mesh, signaling) lives in mobile/shared. On iOS there are two Swift files between that logic and the system. AppDelegate.swift handles process startup and the audio handoff. CallKitBridge.swift holds the PushKit and CallKit logic behind protocols, so we can unit-test it without a real CXProvider or the Kotlin framework.

Startup order matters. iOS can wake a killed app with a VoIP push, and in that case no scene is created. Everything the push handler needs has to be ready in didFinishLaunching:

AppDelegate.swiftswift
func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
    // ...
    // A killed app woken by a VoIP push creates no scene: the Koin graph
    // must come up here, otherwise accepting from CallKit is dead.
    MainViewControllerKt.ensureKoin()

    // Manual audio: WebRTC does not touch the audio unit until an explicit
    // enable (CallKit didActivate, or an answer/dial outside CallKit).
    RTCAudioSession.sharedInstance().useManualAudio = true

    let controller = CallKitController()
    callKit = controller
    // ...
    let registry = PKPushRegistry(queue: .main)
    registry.delegate = controller
    registry.desiredPushTypes = [.voIP]
    voipRegistry = registry
    return true
}

Apple's guide Responding to VoIP Notifications from PushKit also says to create the provider object early in the app's life cycle. Ours is created inside CallKitController, right here.

Rule 1: every VoIP push becomes a CallKit call before completion

What Apple requires: since the iOS 13 SDK, PushKit VoIP pushes are only for calls. In pushRegistry(_:didReceiveIncomingPushWith:for:completion:) of PKPushRegistryDelegate the app must report the call to CallKit with reportNewIncomingCall. If it doesn't, the system terminates the app, and repeated failures can stop VoIP push delivery to the app completely. For a calling app that is effectively a ban: calls to a sleeping phone stop arriving. The reason is that a VoIP push wakes the app even when it was killed, and Apple ties that privilege to showing the system call screen. If you can't use CallKit, Apple says to use regular notifications from the User Notifications framework instead of PushKit.

Our rule is stricter than "report somewhere in the handler". The report happens synchronously, before completion, for every push, including one we can't parse and a duplicate:

CallKitBridge.swiftswift
func didReceiveVoipPush(_ payload: [AnyHashable: Any], completion: @escaping () -> Void) {
    guard let push = IncomingCallPush.parse(payload) else {
        reportFailedIncomingCall(callerName: "ID Caller")
        completion()
        return
    }
    guard !sessions.values.contains(where: { $0.callId == push.callId }) else {
        // Even a duplicate must pass reportNew before completion; we end the
        // synthetic call at once so signaling isn't woken a second time.
        reportFailedIncomingCall(callerName: push.fallbackCallerName, handle: push.fromId, video: push.video)
        completion()
        return
    }

    let uuid = UUID()
    sessions[uuid] = Session(callId: push.callId, push: push)
    rememberCallKitCall(push.callId)
    let update = CXCallUpdate()
    // Name from the cache right away, Room refines it later.
    update.localizedCallerName = gateway.cachedCallerName(fromId: push.fromId) ?? push.fallbackCallerName
    update.hasVideo = push.video
    update.remoteHandle = CXHandle(type: .generic, value: push.fromId)
    reporter?.reportNewIncomingCall(uuid: uuid, update: update) { _ in }

    gateway.incomingCallPushed(push)
    completion()

    // Name from Room: asynchronously, after the mandatory report.
    gateway.callerName(fromId: push.fromId) { [weak self] name in
        // ... reportCall(uuid:updated:) with the contact name
    }
}

reportFailedIncomingCall reports a call and ends it right away with .failed. The user sees at most a short flash, and we still keep our side of the deal with PushKit. Anything that can be slow, like reading the contact name from the local database or connecting to signaling, runs after the report. The push carries no SDP. It only wakes the WebSocket session, and the session then picks up the buffered offer from the server.

This rule was risk R-01 in our discovery documents before any iOS code existed. The ticket that built the PushKit bridge covered it with XCTests, and today testReportsCallKitImmediately, testMalformedPayloadStillReportsBeforeCompletion and testDuplicatePushStillReportsBeforeCompletion check the order "reportNew, then completion" against a recording fake of CXProvider. One more test reads Info.plist and fails if UIBackgroundModes is missing voip or audio.

Rule 2: audio lives with a call that CallKit knows about

What Apple requires: for calls it knows about, CallKit owns the AVAudioSession. You configure the category and fulfill the answer or start action. The system then activates the session and tells you in provider(_:didActivate:) of CXProviderDelegate, and media should start only after that. When the last call CallKit knows about ends, it deactivates the session, whatever else your app is still playing through it. We learned both halves of this the hard way.

Category before fulfill

On July 5 we ran a field test with a killed iPhone. The push arrived, the WebSocket connected and the phone was ringing within about a second. The user answered on the lock screen, and didActivate never came: without a category configured in advance, CallKit didn't activate the session. In the fix (16f20c5) the category is set in the answer action before fulfill(), and a fallback forces the accept if didActivate hasn't arrived after 3 seconds:

CallKitBridge.swiftswift
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
    guard sessions[action.callUUID] != nil else {
        action.fail()
        return
    }
    // The category must be configured BEFORE fulfill, otherwise CallKit
    // does not activate the audio session and didActivate never comes.
    gateway.prepareAudioSession()
    sessions[action.callUUID]?.answering = true
    pendingAnswers.insert(action.callUUID)
    action.fulfill()
    // Fallback: didActivate sometimes doesn't come, so we force accept
    // and enable audio manually (manual mode).
    let uuid = action.callUUID
    DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
        guard let self, self.pendingAnswers.contains(uuid) else { return }
        self.activatePendingAnswers()
    }
}

The category itself is set from shared Kotlin through Kotlin/Native's AVFoundation bindings. A failure is logged, because without it the call would be silent and give no other sign:

CallSystemIntegration.ios.ktkotlin
private fun configureCallAudioSession() {
    val applied = AVAudioSession.sharedInstance().setCategory(
        category = AVAudioSessionCategoryPlayAndRecord,
        mode = AVAudioSessionModeVoiceChat,
        options = AVAudioSessionCategoryOptionAllowBluetooth,
        error = null,
    )
    if (!applied) {
        // Without a configured session CallKit sends no didActivate and the
        // call stays silent: the failure must show up in diagnostics.
        idcLog("[idc-ck] audio session setCategory failed")
    }
}

The group call CallKit didn't know about

On July 9 a 1:1 call was escalated into a group call (a mesh). The group lived outside CallKit. When the old 1:1 call ended, CallKit closed the only call it knew about and deactivated the audio session. The logs from the Android phone showed the mesh coming up perfectly, but both iPhones were silent.

In the fix (5a71acd) the group emits the same system events as a 1:1 call, so CallKit sees it:

GroupCallSystemEvents.ktkotlin
fun groupCallSystemEvents(states: Flow<GroupCallState>): Flow<CallSystemEvent> = flow {
    var previous: GroupCallState = GroupCallState.Idle
    // ...
    states.collect { next ->
        val prevActive = previous as? GroupCallState.Active
        when {
            next is GroupCallState.Active && prevActive == null -> {
                // ...
                val ringing = previous as? GroupCallState.InviteRinging
                if (ringing?.callId == next.callId) {
                    emit(CallSystemEvent.AnsweredLocally(next.callId))
                } else {
                    val handle = next.members.firstOrNull()?.id ?: next.initiatorId
                    emit(CallSystemEvent.OutgoingStarted(next.callId, handle, false))
                }
            }

            next !is GroupCallState.Active && prevActive != null ->
                emit(CallSystemEvent.CallFinished(prevActive.callId))
            // ...
        }
        // ...
        previous = next
    }
}

Both platform bridges listen to one merged flow of 1:1 and group events. Android had the same kind of bug: its bridge listened only to 1:1 calls, the group wasn't registered in Telecom, and its audio played as media through the loudspeaker. On the Swift side the provider allows maximumCallGroups = 2, because both calls exist for a moment during escalation. Our own audio is switched off only when no live calls remain:

AppDelegate.swiftswift
CallKitSyncBridge.shared.onCallFinished = { [weak controller] callId in
    DispatchQueue.main.async {
        let callKitOwned = controller?.isRegistered(callId: callId) ?? false
        controller?.callFinished(callId: callId)
        // Escalation 1:1 → group: ending the old call must NOT
        // mute the audio of the live group.
        if controller?.hasActiveCalls != true {
            AudioSessionHandoff.disable(deactivateSession: !callKitOwned)
        }
    }
}

Rule 3: outside CallKit, activate the session yourself, all of it

WebRTC runs in manual mode (useManualAudio = true), so its audio unit stays off until we turn it on. Otherwise WebRTC can start audio before CallKit has activated the session, and the result is intermittent silence. Usually the signal to turn it on is didActivate.

Some calls never get a didActivate. The server sends a VoIP push for group.invite only to invitees who are offline. An invitee who is online gets the invite over the WebSocket, sees our own Compose incoming screen and taps Accept in the app. There is no CallKit call, so there is no didActivate. The same is true for an outgoing call if CallKit rejects CXStartCallAction. In these cases AppDelegate calls AudioSessionHandoff.enable() itself.

The silent in-app answer

On July 24 we tested on an iPhone 14 Pro Max. 1:1 audio passed on every path, but a group call answered with the in-app button had no sound. enable() told WebRTC the session was active and turned its audio on. But no code set the category or called setActive(true), so WebRTC had "activated" a dead session. The fix (b25dfff) adds the two missing calls:

AppDelegate.swiftdiff
     static func enable() {
         guard !active else { return }
         active = true
+        let av = AVAudioSession.sharedInstance()
+        // In-app answer (button in the Compose UI): nobody set the category
+        // or activated the session, so audioSessionDidActivate "activated" a
+        // dead session. The CallKit path repeats these calls idempotently.
+        try? av.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth])
+        try? av.setActive(true)
         let rtc = RTCAudioSession.sharedInstance()
-        rtc.audioSessionDidActivate(AVAudioSession.sharedInstance())
+        rtc.audioSessionDidActivate(av)
         rtc.isAudioEnabled = true
     }

The active flag makes enable() idempotent. On the CallKit path didActivate calls it too. There the category is already set and the system has already activated the session, so repeating those calls does no harm.

Release only what you activated

The same commit made disable() call setActive(false) every time. The comment said that after a CallKit call this repeat was a no-op. On September 24 we found it wasn't. When we deactivated a session CallKit owned, the next outgoing call in the same process got no didActivate, so the speaker override didn't apply and the video dialing tone played through the earpiece. Now the bridge remembers which calls went through CallKit, and only those outside CallKit release the session themselves:

AppDelegate.swiftswift
/// [deactivateSession]: only for a call outside CallKit. A session owned by
/// CallKit is deactivated by the system in didDeactivate; our setActive(false)
/// on it broke CallKit for the next outgoing call in the same process.
static func disable(deactivateSession: Bool) {
    guard active else { return }
    active = false
    queue.async {
        let rtc = RTCAudioSession.sharedInstance()
        rtc.isAudioEnabled = false
        rtc.audioSessionDidDeactivate(AVAudioSession.sharedInstance())
        if deactivateSession {
            try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
        }
    }
}

The queue is a separate fix (6b45c7a). Activating the session and starting the WebRTC audio unit took about 0.3 s on the main thread, and HangTracer flagged it on the device. Both now run on a serial queue. The queue keeps enable and disable in order, and the active flag stays on the main thread.

Checklist

  • Create PKPushRegistry and CXProvider in didFinishLaunching, and set up everything the push handler depends on there. A VoIP wake-up creates no scene.
  • Report every VoIP push with reportNewIncomingCall synchronously, before completion. That includes garbage payloads and duplicates: report them, then end them with .failed.
  • Do slow work after the report, and update the call with reportCall(with:updated:).
  • Test the order with a recording fake of CXProvider, and test UIBackgroundModes for voip and audio.
  • Set the AVAudioSession category before fulfill() of CXAnswerCallAction, and have a fallback if didActivate doesn't come.
  • Every kind of call that plays audio must be known to CallKit, including group calls and escalations. Size maximumCallGroups for the transition.
  • Never call setActive(false) on a session CallKit owns. Turn your own audio off only when no live calls remain.
  • Use RTCAudioSession manual mode, and enable WebRTC audio only once the session is really active.
  • On paths without CallKit, make all three calls yourself: setCategory, setActive(true), then audioSessionDidActivate with isAudioEnabled.
  • Keep setActive off the main thread, on a serial queue.
  • For App Review: PushKit VoIP and CallKit don't depend on the notification permission, so don't block entry to the app on it. That was our main argument in our reply to Apple after a rejection.
ShareXLinkedIn

ONLYAPPS Team

Novi Sad, Serbia · onlyapps.org

Contact

We use analytics cookies only with your consent. Essential cookies keep the site working. Privacy policy