# CallKit Patterns

Longer examples that back the `callkit-voip` skill. Read its SKILL.md first;
this file assumes the provider setup, push rules and audio rules described
there.

## An observable call manager

A `@MainActor` model that SwiftUI can watch. It owns the provider and the
controller and keeps a table of live calls.

```swift
import CallKit
import Observation

struct LiveCall: Identifiable, Sendable {
    let id: UUID
    let address: String
    let outgoing: Bool
    var held = false
    var muted = false
    var connected = false
}

@Observable @MainActor
final class Switchboard: NSObject {
    @ObservationIgnored let provider: CXProvider
    @ObservationIgnored let controller = CXCallController()
    private(set) var calls: [UUID: LiveCall] = [:]

    override init() {
        let setup = CXProviderConfiguration()
        setup.supportedHandleTypes = [.phoneNumber]
        setup.includesCallsInRecents = true
        setup.maximumCallsPerCallGroup = 1
        setup.maximumCallGroups = 2
        setup.supportsVideo = true
        provider = CXProvider(configuration: setup)
        super.init()
        provider.setDelegate(self, queue: nil)
    }

    func reportIncoming(id: UUID, address: String, name: String) async throws {
        let info = CXCallUpdate()
        info.localizedCallerName = name
        info.remoteHandle = CXHandle(type: .generic, value: address)
        info.supportsDTMF = true
        info.supportsHolding = true
        info.supportsUngrouping = false
        info.supportsGrouping = false
        try await provider.reportNewIncomingCall(with: id, update: info)
        calls[id] = LiveCall(id: id, address: address, outgoing: false)
    }

    func startCall(to address: String) async throws {
        let id = UUID()
        let action = CXStartCallAction(call: id, handle: CXHandle(type: .phoneNumber, value: address))
        try await controller.request(CXTransaction(action: action))
        calls[id] = LiveCall(id: id, address: address, outgoing: true)
    }

    func hangUp(_ id: UUID) async throws {
        try await controller.request(CXTransaction(action: CXEndCallAction(call: id)))
    }

    func setHeld(_ id: UUID, _ held: Bool) async throws {
        try await controller.request(CXTransaction(action: CXSetHeldCallAction(call: id, onHold: held)))
    }

    func setMuted(_ id: UUID, _ muted: Bool) async throws {
        try await controller.request(CXTransaction(action: CXSetMutedCallAction(call: id, muted: muted)))
    }
}
```

A call enters `calls` only after its incoming report succeeds; an outgoing call
enters after the start transaction was accepted. Hanging up, holding and muting
all follow the same shape: build the action, wrap it in a `CXTransaction`, send
it with `request`.

## Hold and mute in the delegate

The provider calls its delegate on the main queue here (`queue: nil`), so the
conformance is `@preconcurrency`: the methods stay main-actor isolated and
touch `calls` directly. Marking them `nonisolated` and hopping back with
`Task { @MainActor in ... }` does not compile under Swift 6, because the task
would carry the non-`Sendable` `CXAction` off the delegate call ("sending
'hold' risks causing data races").

```swift
extension Switchboard: @preconcurrency CXProviderDelegate {
    func providerDidReset(_ cx: CXProvider) {
        Task {
            for id in calls.keys { await CallMedia.shared.disconnect(id) }
            calls.removeAll()
        }
    }

    func provider(_ cx: CXProvider, perform hold: CXSetHeldCallAction) {
        let id = hold.callUUID
        calls[id]?.held = hold.isOnHold
        if hold.isOnHold { CallMedia.shared.pause(id) } else { CallMedia.shared.resume(id) }
        hold.fulfill()
    }

    func provider(_ cx: CXProvider, perform mute: CXSetMutedCallAction) {
        calls[mute.callUUID]?.muted = mute.isMuted
        CallMedia.shared.setMicrophoneMuted(mute.isMuted)
        mute.fulfill()
    }
}
```

Muting only silences the microphone input. Upstream audio keeps flowing,
because iOS 26 call translation depends on it.

The answer, start, end, `didActivate` and `didDeactivate` handlers are the same
as in SKILL.md, with bookkeeping added: set `connected = true` once media
connects, and remove the entry when the call ends.

## Several calls at once

When a second call rings while one is active and the user accepts it, CallKit
holds the first call on its own. The app sees a `CXSetHeldCallAction` for the
first call and must pause or resume its media stream (for example the RTP
session) in response. How many calls may coexist is set by
`maximumCallGroups` and `maximumCallsPerCallGroup` in the configuration.

## Watching call state

`CXCallObserver` reports every call on the device, which is useful outside the
provider delegate, for example in a status view.

```swift
import CallKit

final class CallWatcher: NSObject, CXCallObserverDelegate {
    private let monitor = CXCallObserver()

    override init() {
        super.init()
        monitor.setDelegate(self, queue: .main)
    }

    func callObserver(_ watcher: CXCallObserver, callChanged call: CXCall) {
        let phase: String
        if call.hasEnded {
            phase = "ended"
        } else if call.hasConnected {
            phase = "connected"
        } else if call.isOutgoing {
            phase = "dialing"
        } else {
            phase = "ringing"
        }
        Log.calls.info("\(call.uuid) is \(phase)")
    }
}
```

## Filtering encrypted VoIP pushes

When the server sends end-to-end encrypted content and cannot tell whether it
is a call, a notification service extension decrypts it and hands call
payloads to CallKit.

```swift
import CallKit
import UserNotifications

final class PushFilter: UNNotificationServiceExtension {
    override func didReceive(_ incoming: UNNotificationRequest,
                             withContentHandler deliver: @escaping (UNNotificationContent) -> Void) {
        guard let sealed = incoming.content.userInfo["sealed"] as? String,
              let opened = Envelope.open(sealed) else {
            deliver(incoming.content)
            return
        }
        nonisolated(unsafe) let deliver = deliver
        CXProvider.reportNewIncomingVoIPPushPayload(opened) { failure in
            guard failure != nil else {
                deliver(UNNotificationContent())
                return
            }
            let missed = UNMutableNotificationContent()
            missed.title = String(localized: "Missed call")
            deliver(missed)
        }
    }
}
```

Content that was not encrypted passes through untouched. The content handler
is not `Sendable`, and both forms of `reportNewIncomingVoIPPushPayload` need it
somewhere concurrent: the completion form runs its callback as `@Sendable`, and
the async form needs a `Task` whose closure Swift 6 rejects for capturing the
handler. The `nonisolated(unsafe)` copy lets the `@Sendable` callback hold it. A failed report turns
into a missed-call notification; a successful one is suppressed with empty
content. If the server does know the push is a call, skip all of this and use
an ordinary PushKit VoIP push. Entitlement required for the extension:
`com.apple.developer.usernotifications.filtering`.

## Incremental Call Directory updates

Once the initial complete load has run, the extension can add and remove individual numbers
instead of rebuilding everything.

```swift
extension SpamListProvider {
    func applyChanges(to context: CXCallDirectoryExtensionContext) {
        let delta = SharedStore.pendingDelta()
        for number in delta.unblocked {
            context.removeBlockingEntry(withPhoneNumber: number)
        }
        for number in delta.unlabeled {
            context.removeIdentificationEntry(withPhoneNumber: number)
        }
        for number in delta.blocked.sorted() {
            context.addBlockingEntry(withNextSequentialPhoneNumber: number)
        }
        for pair in delta.labeled.sorted(by: { $0.key < $1.key }) {
            context.addIdentificationEntry(withNextSequentialPhoneNumber: pair.key, label: pair.value)
        }
        SharedStore.clearDelta()
    }
}
```

Additions are sorted before they are added. The system runs
`beginRequest(with:)` when it loads the extension, never per incoming call, so
any web lookup and dataset sync happens in the containing app before it calls
`reloadExtension(withIdentifier:)`.

## Testing VoIP locally

- Fire test pushes with the Push Notifications Console, or with any APNs
  client you run from a terminal. The topic is the bundle identifier with `.voip` appended, for example
  `com.example.dialer.voip`.
- A test payload needs only an empty `aps` plus your own keys:

```json
{
  "aps": {},
  "from": "+14155550123",
  "display": "Test Caller",
  "video": false
}
```

- Without a server, a debug-only helper can call the incoming report directly:

```swift
#if DEBUG
extension Switchboard {
    func simulateIncomingCall() async {
        try? await reportIncoming(id: UUID(), address: "+14155550123", name: "Test Caller")
    }
}
#endif
```

- Check the Call Directory extension state from the app:

```swift
func checkBlockerStatus() {
    let manager = CXCallDirectoryManager.sharedInstance
    manager.getEnabledStatusForExtension(withIdentifier: "org.sample.dialer.blocker") { state, failure in
        switch state {
        case .enabled: Log.calls.info("blocker active")
        case .disabled: Log.calls.info("ask the user to enable it in Settings")
        case .unknown: Log.calls.info("status unknown: \(String(describing: failure))")
        @unknown default: break
        }
    }
}
```
