WWDC Quick Look 💓 By SwiftGGTeam
Create live communication experiences

Create live communication experiences

Watch original video

Highlight

Apple introduces the LiveCommunicationKit framework, giving third-party real-time communication apps the same system-level full-screen incoming call UI, Dynamic Island multitasking presentation, Phone app Recents entries, and Siri launch support as system phone calls, replacing the traditional CXProvider API.

Key Takeaways

From CXProvider to LiveCommunicationKit

In the past, developers building VoIP calls on iOS mainly relied on CallKit’s CXProvider API. It could provide a basic system incoming-call interface, but it was not flexible enough, and support for modern communication scenarios such as group chats, call merging, and video calls was not very intuitive.

LiveCommunicationKit is a new framework built specifically for these problems. It abstracts a “call” into the more general concept of a “conversation.” One-to-one audio, video calls, group conversations, merged sessions, and other forms all connect to system-level UI.

(00:53)

The Conversation Lifecycle

A conversation moves through five states from creation to end:

  • idle: reported to the system, and the device starts ringing
  • joining: the user answers, and the app is establishing the connection
  • joined: the connection is ready, and audio/video begins transmitting
  • leaving: the user ends the call, and the app is cleaning up resources
  • left: cleanup is complete, and the conversation ends

(04:10)

Every state transition is actively reported by the app through ConversationManager. The system updates the Lock Screen, Dynamic Island, and Recents UI based on the current state.

Unified State Management

LiveCommunicationKit’s core design is a single source of truth. Whether the user answers from the Lock Screen, taps hang up in the Dynamic Island, or presses a button inside the app, every interaction becomes a ConversationAction and enters the app through the same ConversationManagerDelegate callback.

This means the app does not need to maintain two sets of state logic. System UI and app UI stay synchronized.

(06:00)

Details

Configure ConversationManager

Every app that uses LiveCommunicationKit needs a ConversationManager instance. It is usually created at app launch and kept for the lifetime of the app.

import LiveCommunicationKit

let configuration = ConversationManager.Configuration(
  ringtoneName: "SampleRingtone.caf",
  iconTemplateImageData: UIImage(named: "SampleIcon")?.pngData(),
  maximumConversationGroups: 1,
  maximumConversationsPerConversationGroup: 2,
  includesConversationInRecents: true,
  supportsVideo: true,
  supportedHandleTypes: [.phoneNumber, .emailAddress]
)

let manager = ConversationManager(configuration: configuration)
manager.delegate = self

Key points:

  • ringtoneName: the ringtone file in the app bundle, played for incoming calls
  • iconTemplateImageData: the app icon template used in system UI
  • maximumConversationGroups and maximumConversationsPerConversationGroup: limits for merged conversation hierarchy
  • includesConversationInRecents: whether call records appear in Recents in the Phone app
  • supportsVideo: enables the video button in system UI
  • supportedHandleTypes: the contact handle types the app supports, which affects contact matching

(06:41)

Handle Incoming Calls

Incoming calls wake the app through a PushKit VoIP push. The app must report the conversation before the delegate method returns, or the system will terminate the app.

import LiveCommunicationKit
import PushKit

final class SamplePushHandler: NSObject, PKPushRegistryDelegate {
  func pushRegistry(
    _ registry: PKPushRegistry,
    didReceiveIncomingVoIPPushWith payload: PKPushPayload,
    metadata: PKVoIPPushMetadata) async {

    guard let (handle, uuid) = parseConversationPayload(from: payload) else { return }

    let capabilities = [.video, .pausing, .merging]
    let update = Conversation.Update(members: [handle], capabilities: capabilities)
    try? await manager.reportNewIncomingConversation(uuid: uuid, update: update)
  }
}

Key points:

  • parseConversationPayload: extracts the handle, meaning the caller identifier, and UUID, meaning the conversation’s unique identifier, from the push payload
  • Conversation.Update: describes the initial conversation state, including members and capabilities
  • The capabilities array declares what the conversation supports, and the system uses it to decide which controls to show
  • reportNewIncomingConversation must finish before the method returns, or the app is terminated

(09:22)

Respond to System Actions

All user interactions enter the app through ConversationManagerDelegate’s perform action method.

import LiveCommunicationKit

final class SampleDelegate: ConversationManagerDelegate {
  func conversationManager(
    _ manager: ConversationManager,
    perform action: ConversationAction
  ) {
    switch action {
    case let action as JoinConversationAction:
      handleJoinAction(action)
    case let action as EndConversationAction:
      handleEndAction(action)
    case let action as StartConversationAction:
      handleStartAction(action)
    case let action as MergeConversationAction:
      handleMergeAction(action)
    default:
      action.fail()
    }
  }
}

Key points:

  • JoinConversationAction: the user answers an incoming call
  • EndConversationAction: the user hangs up
  • StartConversationAction: the user starts a call from inside the app
  • MergeConversationAction: the user merges two conversations
  • Unrecognized actions must call fail() so the system can clean up UI state

(09:57)

Handle the Answer Action

extension SampleDelegate {
  func handleJoinAction(_ action: JoinConversationAction) {
    guard let conversation = manager.conversations.first(where: { $0.uuid == action.conversationUUID }) else {
      return action.fail()
    }

    manager.reportConversationEvent(.conversationStartedConnecting(.now), for: conversation)

    Task {
      do {
        try await setupMediaStream(with: action.conversationUUID)
        manager.reportConversationEvent(.conversationConnected(.now), for: conversation)
        action.fulfill(dateConnected: .now)
      } catch {
        action.fail()
      }
    }
  }
}

Key points:

  • First find the matching conversation by UUID. If it cannot be found, call fail()
  • conversationStartedConnecting: tells the system to enter the joining state, and the UI shows “connecting”
  • setupMediaStream: the app’s own media-connection logic, running in a Task so the delegate can remain responsive
  • conversationConnected: tells the system the media is ready and the conversation has entered the joined state
  • fulfill(dateConnected:): marks the action complete, and the system updates UI
  • Any error goes through fail(), and the system cleans up automatically

(10:13)

Start an Outgoing Call

When starting a call from inside the app, create a StartConversationAction and execute it through ConversationManager.

let startAction = StartConversationAction(
  conversationUUID: UUID(),
  handles: [Handle(type: .phoneNumber, value: "+1-650-555-0199", displayName: "Ryan Notch")],
  isVideo: false
)

try await manager.perform([startAction])

Key points:

  • conversationUUID: the newly generated unique identifier
  • handles: the array of recipient contact handles
  • isVideo: whether this is a video call
  • manager.perform: submits the action to the system. After the system updates UI, it forwards the action to the delegate for handling

(12:14)

Group Conversations and Session Merging

Group conversations distinguish between two member lists:

  • members: all invited participants
  • activeRemoteMembers: participants currently sending media
let adam = Handle(type: .emailAddress,
                  value: "[email protected]",
                  displayName: "Adam Halwani")
let david = Handle(type: .emailAddress,
                   value: "[email protected]",
                   displayName: "David Evans")
let ryan = Handle(type: .phoneNumber,
                  value: "+16505550199",
                  displayName: "Ryan Notch")

let startAction = StartConversationAction(
  conversationUUID: UUID(),
  handles: [david, ryan],
  isVideo: false
)
try await manager.perform([startAction])

Report updates when membership changes:

let update = Conversation.Update(
  localMember: adam,
  members: [david, ryan],
  activeRemoteMembers: [david, ryan],
  capabilities: [.merging, .pausing, .unmerging]
)

manager.reportConversationEvent(
  .conversationUpdated(update),
  for: conversation
)

(13:51)

Merge Two Conversations

When the user merges two parallel conversations into one group conversation, the system sends a MergeConversationAction.

extension SampleDelegate {
  func handleMergeAction(_ action: MergeConversationAction) {
    let sourceUUID = action.conversationUUID
    let targetUUID = action.conversationUUIDToMergeWith

    guard manager.conversations.contains(where: { $0.uuid == sourceUUID }),
          manager.conversations.contains(where: { $0.uuid == targetUUID }) else {
      return action.fail()
    }

    Task {
      do {
        let update = try await combineStreams(from: sourceUUID, into: targetUUID)
        manager.reportConversationEvent(.conversationUpdated(update), for: target)
        action.fulfill()
      } catch {
        action.fail()
      }
    }
  }
}

Key points:

  • conversationUUID: the identifier of the source conversation
  • conversationUUIDToMergeWith: the identifier of the target conversation
  • Verify both conversations exist before merging
  • combineStreams: the app’s own server-side logic for merging two media streams
  • After the merge completes, report the update and call fulfill()
  • Unmerging follows the exact same delegate pattern

(15:33)

Ideas to Build

1. Turn any real-time interactive app into a system-level call

Customer support, remote collaboration, and game voice chat can all integrate LiveCommunicationKit. Users answer directly from the Lock Screen, see status in the Dynamic Island, and get an experience consistent with phone calls. The entry APIs are ConversationManager and reportNewIncomingConversation.

2. Use group merging to add someone to a conversation

In a support scenario, a frontline agent can talk one-on-one with a user, then merge in an expert session when technical help is needed, without making the user hang up and call again. Start by declaring the .merging capability and handling MergeConversationAction.

3. Make call history an app entry point

After enabling includesConversationInRecents: true, users can call back directly from Recents in the Phone app. Combined with StartCallIntent donation, Siri can start calls directly too. This is a natural traffic entry point that reduces user drop-off.

4. Switch audio and video dynamically

Capabilities can be updated during the conversation lifecycle. If a user turns on the camera in the middle of an audio call, you only need to update capabilities to include .video, and the system UI responds immediately. There is no need to rebuild the entire conversation.

5. Use Handle to connect with the contact system

Map in-app user identifiers to .phoneNumber or .emailAddress Handle types, and the system can automatically match contact avatars and names from Contacts. For unknown numbers, it uses displayName as the fallback.

  • 121 - Siri and iPhone features. Learn how to integrate StartCallIntent so Siri can start calls directly
  • 223 - Live Activities. Learn how to show additional information with a Live Activity during a call
  • 310 - Shortcuts. Learn about App Intents donation so call history can be found through system search and Shortcuts
  • 345 - New App Intents features. Understand intent donation and NSUserActivity integration in depth
  • 369 - Bluetooth. Learn how to handle Bluetooth headset route changes and audio path management during calls

Comments

GitHub Issues · utterances