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):
Tap "Start Fake Call".
Within 5 seconds, tap the small route picker button and leave its list open.
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 !
1
0
22