AVRoutePickerView's presented route list is automatically dismissed when CXProvider reports the call as connected (`reportOutgoingCall(with:connectedAt:)`)

Hi,

I want my user to be able to change their audio output device while calling someone, even before the call is established. But I've run into an issue combining CallKit and AVRoutePickerView.

On iOS 26 (Xcode 26.5, tested on iPhone 13 Mini): when my AVRoutePickerView's route list is presented while my call is still connecting (not yet established), the presented list is automatically dismissed the moment the app reports the call as connected via CXProvider.reportOutgoingCall(with:connectedAt:).

That doesn't seem natural to me — it should stay open, since the user is actively interacting with unrelated system UI at that moment.

This happens even when nothing about the AVAudioSession itself changes: no category change, no route change, no didActivate/didDeactivate callback fires around that time. The dismissal is caused by the CallKit "connected" state transition itself, but I don't know why, or whether it's intentional.

Steps to reproduce (sample project below):

  1. Tap "Start Fake Call".
  2. Within 5 seconds, tap the small route picker button and leave its list open.
  3. Wait — at t+5s the app reports the call connected via reportOutgoingCall(connectedAt:), and the list closes itself right after.

Is this normal? And is there an official, CallKit-sanctioned way to report a call's connected state without this side effect — some way to keep the route picker stable across that transition?

The two workarounds I've found both have real downsides:

  • Calling reportOutgoingCall(connectedAt:) at the very start of the call, before the callee has actually answered , but then CallKit no longer reflects the real call state (Recents/duration would include ringing time, and calls that are never answered would still show as "connected").
  • Disabling audio device selection entirely until the call is established... works, but hurts the user experience, since users may want to switch output before the call connects.

Here's the minimal reproduction:

import CallKit
import SwiftUI

struct ContentView: View {
    @StateObject private var demo = CallKitDemo()

    var body: some View {
        VStack(spacing: 24) {
            Text(demo.statusText)
                .font(.system(.body, design: .monospaced))
                .multilineTextAlignment(.center)

            Button("Start Fake Call") {
                demo.startFakeCall()
            }
            .disabled(demo.isCallInProgress)

            RoutePickerView()
                .frame(width: 60, height: 60)
        }
        .padding()
    }
}

private struct RoutePickerView: UIViewRepresentable {
    func makeUIView(context: Context) -> AVRoutePickerView {
        let picker = AVRoutePickerView()
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIView(_ uiView: AVRoutePickerView, context: Context) {}

    func makeCoordinator() -> Coordinator { Coordinator() }

    final class Coordinator: NSObject, AVRoutePickerViewDelegate {
        func routePickerViewWillBeginPresentingRoutes(_ routePickerView: AVRoutePickerView) {
            print("[REPRO]", Date(), "picker WILL present routes")
        }

        func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) {
            print("[REPRO]", Date(), "picker DID end presenting routes <- dismissed here")
        }
    }
}

@MainActor
final class CallKitDemo: NSObject, ObservableObject {
    @Published var statusText = "Idle"
    @Published var isCallInProgress = false

    private let provider: CXProvider = {
        let configuration = CXProviderConfiguration()
        configuration.supportsVideo = false
        configuration.maximumCallGroups = 1
        configuration.maximumCallsPerCallGroup = 1
        configuration.supportedHandleTypes = [.generic]
        return CXProvider(configuration: configuration)
    }()

    private let callController = CXCallController()
    private var currentCallUUID: UUID?

    override init() {
        super.init()
        provider.setDelegate(self, queue: nil)
    }

    func startFakeCall() {
        let uuid = UUID()
        currentCallUUID = uuid
        isCallInProgress = true
        statusText = "Requesting CXStartCallAction..."

        let handle = CXHandle(type: .generic, value: "repro-call")
        let startAction = CXStartCallAction(call: uuid, handle: handle)

        callController.request(CXTransaction(action: startAction)) { error in
            if let error {
                print("[REPRO]", Date(), "CXStartCallAction request failed:", error)
            }
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in
            guard let self, currentCallUUID == uuid else { return }

            print("[REPRO]", Date(), "reportOutgoingCall(connectedAt:)")
            statusText = "Reported connected at t+5s"
            provider.reportOutgoingCall(with: uuid, connectedAt: Date())
        }

        DispatchQueue.main.asyncAfter(deadline: .now() + 8) { [weak self] in
            guard let self, currentCallUUID == uuid else { return }

            callController.request(CXTransaction(action: CXEndCallAction(call: uuid))) { _ in }
            currentCallUUID = nil
            isCallInProgress = false
            statusText = "Call ended"
        }
    }
}

extension CallKitDemo: CXProviderDelegate {
    func providerDidReset(_ provider: CXProvider) {}

    func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
        action.fulfill()
    }

    func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
        action.fulfill()
    }

    func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
        print("[REPRO]", Date(), "didActivate")
    }

    func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
        print("[REPRO]", Date(), "didDeactivate")
    }
}

Thanks for your help !

This happens even when nothing about the AVAudioSession itself changes: no category change, no route change, no didActivate/didDeactivate callback fires around that time. The dismissal is caused by the CallKit "connected" state transition itself, but I don't know why, or whether it's intentional.

What's actually going on here is that CallKit is effectively a UI framework, so your interactions with it can end up generating side effects in your UI, even though it doesn't "seem" like what you're actually doing. Adding to the complexity, all of its UI operates outside your process and often has relatively minor visible impact, which means those changes aren't really visible to your app or all that "noticeable" to the user.

Is this normal?

It's normal in the sense that I'm not particularly surprised it's happening. I need to take a much closer look to tell you EXACTLY what happened, but you see very similar things happen if/when system UI elements "forcibly" take control of the system (for example, our large window locations usage warnings).

And is there an official, CallKit-sanctioned way to report a call's connected state without this side effect — some way to keep the route picker stable across that transition?

I don't really think so. On the app side, you don't have any control over the state transitions, assuming your app even saw them. You can (and probably should) file a CallKit bug; however, I'll warn you that it's a tricky one to fix, since CallKit itself isn't really causing the issue either. Even at the system level, it's a tricky issue, since the correct behavior for a 1s interaction can be very different than what's correct for a 5m interaction.

The two workarounds I've found both have real downsides:

One more option I'd throw out here, which is to instead DELAY calling reportOutgoingCall(connectedAt:) IF the routePicker happens to be up. I'd determine the exact amount of time based on what "felt" right, but my guess is that delaying the connect for a <5s would be enough time that the user could reliably complete most interactions.

I'd also think about this as a design issue, not just a usability issue. A big part of what makes the dismissal feel "wrong" is that the connected transition itself isn't all that visible, so it looks/feels like the control dismissed for "no reason". Making the state transition "more visible" can help mitigate that concern by better connecting cause and effect. You can also fiddle with the timing of things by updating your UI, pausing VERY briefly, THEN make the reportOutgoingCall() which forces the dismissal. Again, this all enforces the idea that everything is working properly.

__
Kevin Elliott
DTS Engineer, CoreOS/Hardware

AVRoutePickerView's presented route list is automatically dismissed when CXProvider reports the call as connected (&#96;reportOutgoingCall(with:connectedAt:)&#96;)
 
 
Q