mattermost-mobile-upstream/libraries/@mattermost/calls-native/ios/Source/Managers/CallKitProvider.swift
Elias Nahum 6edb76ec68
replace react-native-incall-manager with @mattermost/calls-native for audio management (#9973)
* replace react-native-incall-manager with @mattermost/calls-native for audio management

Removes react-native-incall-manager entirely and implements audio session
lifecycle, route selection, and ringtone playback directly in calls-native
on both iOS (AVAudioSession) and Android (AudioManager). Adds smart
priority-based auto-routing (BT > WiredHeadset > Earpiece) with user-intent
pinning. Unifies AudioDevice/AudioRoute types in calls-native as the single
source of truth.

* feedback review

* feedback on tests

* feedback review

* have callkit use the ringtone selected by the user

* feedback review

* destroy db after test

* route to wired headset on android

* Android bring back vibration
2026-08-06 07:38:54 +08:00

412 lines
18 KiB
Swift

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import AVFoundation
import CallKit
import Foundation
import Gekidou
import UIKit
import UserNotifications
struct IncomingCallRequest {
let channelID: String
let serverID: String
let postID: String
let threadID: String
let callerID: String
let callerName: String
let channelName: String
/// Raw PushKit payload. Used to drive the ack-receipt round-trip in
/// `reportIncomingCall` so the CallKit UI can be updated with the
/// resolved name when the server returns enriched fields (IdLoaded
/// configs). Empty for synthetic requests (outbound, decode failures).
let rawUserInfo: [AnyHashable: Any]
/// The ringtone filename (without extension) to use for this incoming call,
/// e.g. "calls_calm". nil means use the system default CallKit ringtone.
let ringtoneName: String?
}
private struct CallInfo {
let request: IncomingCallRequest
var isAnswered: Bool
}
@objc public final class CallKitProvider: NSObject, CXProviderDelegate {
private let provider: CXProvider
private let controller = CXCallController()
private weak var bridge: CallsBridge?
private var calls: [UUID: CallInfo] = [:]
private let callsLock = NSLock()
/// Per-call set of action UUIDs of `CXSetMutedCallAction`s we issued
/// ourselves on behalf of JS. The delegate consumes the matching action
/// UUID and skips the echo to JS, breaking the in-app toggleMute →
/// setMuted → delegate → CallMuted → muteMyself loop. Grouping by call
/// UUID lets `clearCallInfo` drop all pending markers when a call ends.
private var jsOriginatedMuteActions: [UUID: Set<UUID>] = [:]
init(bridge: CallsBridge) {
self.bridge = bridge
let configuration = CXProviderConfiguration()
// Calls today is audio + screen share — no front-camera video. Screen
// share doesn't surface through CallKit (it's handled in-app), so
// CallKit's video affordance stays off.
configuration.supportsVideo = false
configuration.maximumCallGroups = 2
configuration.maximumCallsPerCallGroup = 1
configuration.supportedHandleTypes = [.generic]
configuration.includesCallsInRecents = false
if let iconData = Self.callKitIconTemplateData() {
configuration.iconTemplateImageData = iconData
}
self.provider = CXProvider(configuration: configuration)
super.init()
self.provider.setDelegate(self, queue: .main)
GekidouLogger.shared.log(.info, "CallKitProvider: initialized")
}
// MARK: - Inbound (PushKit-triggered)
func reportIncomingCall(_ request: IncomingCallRequest,
pushCompletion: @escaping () -> Void) {
// channelID + serverID are the minimum needed for the JS layer to
// resolve which server and which channel to join. Without them we
// satisfy iOS's "must report" rule and end immediately so the
// entitlement isn't revoked.
if request.channelID.isEmpty || request.serverID.isEmpty {
GekidouLogger.shared.log(.warning,
"CallKitProvider: incomplete VoIP payload, bailing (channelID=\(request.channelID) serverID=\(request.serverID))")
let uuid = UUID()
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: "unknown")
provider.reportNewIncomingCall(with: uuid, update: update) { _ in
pushCompletion()
}
provider.reportCall(with: uuid, endedAt: Date(), reason: .answeredElsewhere)
return
}
let uuid = UUID()
let info = CallInfo(request: request, isAnswered: false)
setCallInfo(info, for: uuid)
// Apply the per-call ringtone before reporting. CXProvider reads
// ringtoneSoundName at reportNewIncomingCall time, so updating the
// configuration here (rather than at provider init) is safe and
// avoids recreating the provider.
applyRingtone(request.ringtoneName)
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic,
value: request.callerID.isEmpty ? request.channelID : request.callerID)
// CallKit shows localizedCallerName as the primary display; if it's
// empty it falls back to the handle (a raw ID — terrible UX). Pick
// the best available initial name from the payload; for IdLoaded
// configs the ack-receipt round-trip refreshes it via
// updateCallerName(uuid:name:).
update.localizedCallerName = Self.bestInitialDisplayName(request)
update.hasVideo = false
update.supportsHolding = false
update.supportsGrouping = false
update.supportsUngrouping = false
update.supportsDTMF = false
GekidouLogger.shared.log(.info,
"CallKitProvider: reportNewIncomingCall uuid=\(uuid.uuidString) ringtone=\(request.ringtoneName ?? "system")")
provider.reportNewIncomingCall(with: uuid, update: update) { [weak self] error in
guard let self = self else {
pushCompletion()
return
}
if let error = error {
GekidouLogger.shared.log(.error,
"CallKitProvider: reportNewIncomingCall failed: \(error.localizedDescription)")
self.clearCallInfo(for: uuid)
pushCompletion()
return
}
self.bridge?.send(event: .IncomingCall, body: [
"uuid": uuid.uuidString,
"channelId": request.channelID,
"serverId": request.serverID,
"postId": request.postID,
"threadId": request.threadID,
"callerId": request.callerID,
"callerName": request.callerName,
"channelName": request.channelName,
])
self.ackAndRefreshName(uuid: uuid, userInfo: request.rawUserInfo)
pushCompletion()
}
}
/// POST /notifications/ack to the originating server. For IdLoaded
/// configs the response carries the channel name and sender display
/// name; we splice those into the CallKit UI via reportCall(updated:).
/// Skipped for synthetic requests (empty rawUserInfo) — outbound calls
/// and signature-verification failures.
private func ackAndRefreshName(uuid: UUID, userInfo: [AnyHashable: Any]) {
guard !userInfo.isEmpty else { return }
let content = UNMutableNotificationContent()
content.userInfo = userInfo
Gekidou.PushNotification.default.postNotificationReceipt(content) { [weak self] enriched in
guard let self = self, let enriched = enriched else { return }
let info = enriched.userInfo
let resolved = (info["channel_name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
?? (info["sender_name"] as? String).flatMap { $0.isEmpty ? nil : $0 }
guard let name = resolved else { return }
DispatchQueue.main.async {
self.updateCallerName(uuid: uuid, name: name)
}
}
}
// MARK: - Outbound (JS-triggered)
@objc public func reportOutgoingCall(channelID: String,
calleeName: String,
completion: @escaping (UUID?, Error?) -> Void) {
let uuid = UUID()
let request = IncomingCallRequest(channelID: channelID,
serverID: "",
postID: "",
threadID: "",
callerID: "",
callerName: calleeName,
channelName: "",
rawUserInfo: [:],
ringtoneName: nil)
let info = CallInfo(request: request, isAnswered: true)
setCallInfo(info, for: uuid)
let handle = CXHandle(type: .generic, value: calleeName.isEmpty ? channelID : calleeName)
let startAction = CXStartCallAction(call: uuid, handle: handle)
startAction.isVideo = false
startAction.contactIdentifier = calleeName
let transaction = CXTransaction(action: startAction)
controller.request(transaction) { [weak self] error in
guard let self = self else { return }
if let error = error {
GekidouLogger.shared.log(.error,
"CallKitProvider: request startCallAction failed: \(error.localizedDescription)")
self.clearCallInfo(for: uuid)
completion(nil, error)
return
}
self.provider.reportOutgoingCall(with: uuid, startedConnectingAt: nil)
GekidouLogger.shared.log(.info,
"CallKitProvider: reported outgoing call uuid=\(uuid.uuidString)")
completion(uuid, nil)
}
}
@objc public func reportConnected(uuid: UUID) {
provider.reportOutgoingCall(with: uuid, connectedAt: nil)
updateCallInfo(for: uuid) { $0.isAnswered = true }
GekidouLogger.shared.log(.info, "CallKitProvider: reportConnected uuid=\(uuid.uuidString)")
}
/// Replace the display name on an in-progress call. Used after the
/// ack-receipt round-trip resolves the human name for an IdLoaded push
/// (the original `reportIncomingCall` had only the channel/sender id).
@objc public func updateCallerName(uuid: UUID, name: String) {
guard !name.isEmpty else { return }
let update = CXCallUpdate()
update.localizedCallerName = name
provider.reportCall(with: uuid, updated: update)
GekidouLogger.shared.log(.info,
"CallKitProvider: updateCallerName uuid=\(uuid.uuidString)")
}
@objc public func reportEnded(uuid: UUID, reason: CXCallEndedReason) {
provider.reportCall(with: uuid, endedAt: nil, reason: reason)
clearCallInfo(for: uuid)
GekidouLogger.shared.log(.info,
"CallKitProvider: reportEnded uuid=\(uuid.uuidString) reason=\(reason.rawValue)")
}
@objc public func setMuted(uuid: UUID, muted: Bool, completion: @escaping (Error?) -> Void) {
// Mark this specific action so the delegate skips its echo. Tracking
// by action.uuid (not call uuid) ensures concurrent lock-screen
// taps for the same call still produce delegate events JS can react
// to — only this exact request gets silently consumed.
let action = CXSetMutedCallAction(call: uuid, muted: muted)
callsLock.lock()
jsOriginatedMuteActions[uuid, default: []].insert(action.uuid)
callsLock.unlock()
let transaction = CXTransaction(action: action)
controller.request(transaction) { [weak self] error in
if error != nil {
self?.consumeJSOriginatedMuteAction(callUUID: uuid, actionUUID: action.uuid)
}
completion(error)
}
}
/// Removes an action UUID from the per-call marker set and drops the
/// outer entry once empty. Returns whether the marker was present.
@discardableResult
private func consumeJSOriginatedMuteAction(callUUID: UUID, actionUUID: UUID) -> Bool {
callsLock.lock()
defer { callsLock.unlock() }
let removed = jsOriginatedMuteActions[callUUID]?.remove(actionUUID) != nil
if jsOriginatedMuteActions[callUUID]?.isEmpty == true {
jsOriginatedMuteActions.removeValue(forKey: callUUID)
}
return removed
}
// MARK: - CXProviderDelegate
public func providerDidReset(_ provider: CXProvider) {
callsLock.lock()
calls.removeAll()
jsOriginatedMuteActions.removeAll()
callsLock.unlock()
GekidouLogger.shared.log(.info, "CallKitProvider: providerDidReset")
}
public func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
guard var info = callInfo(for: action.callUUID) else {
action.fail()
return
}
info.isAnswered = true
setCallInfo(info, for: action.callUUID)
bridge?.audioSession.configureForCall()
bridge?.send(event: .CallAnswered, body: ["uuid": action.callUUID.uuidString])
action.fulfill()
}
public func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
let info = callInfo(for: action.callUUID)
let event: CallsBridge.Event = (info?.isAnswered == true) ? .CallEnded : .CallDeclined
bridge?.send(event: event, body: ["uuid": action.callUUID.uuidString])
clearCallInfo(for: action.callUUID)
action.fulfill()
}
public func provider(_ provider: CXProvider, perform action: CXSetMutedCallAction) {
let jsOriginated = consumeJSOriginatedMuteAction(callUUID: action.callUUID, actionUUID: action.uuid)
if !jsOriginated {
bridge?.send(event: .CallMuted, body: [
"uuid": action.callUUID.uuidString,
"muted": action.isMuted,
])
}
action.fulfill()
}
public func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
bridge?.audioSession.configureForCall()
action.fulfill()
}
public func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
bridge?.audioSession.activated(audioSession)
}
public func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
bridge?.audioSession.deactivated(audioSession)
}
// MARK: - Call info bookkeeping
private func callInfo(for uuid: UUID) -> CallInfo? {
callsLock.lock()
defer { callsLock.unlock() }
return calls[uuid]
}
private func setCallInfo(_ info: CallInfo, for uuid: UUID) {
callsLock.lock()
calls[uuid] = info
callsLock.unlock()
}
private func updateCallInfo(for uuid: UUID, _ mutation: (inout CallInfo) -> Void) {
callsLock.lock()
if var info = calls[uuid] {
mutation(&info)
calls[uuid] = info
}
callsLock.unlock()
}
private func clearCallInfo(for uuid: UUID) {
callsLock.lock()
calls.removeValue(forKey: uuid)
jsOriginatedMuteActions.removeValue(forKey: uuid)
callsLock.unlock()
}
// MARK: - Ringtone configuration
/// Update the provider's ringtoneSoundName for the next incoming call.
/// CXProvider reads this at reportNewIncomingCall time, so mutating
/// provider.configuration here (rather than recreating the provider)
/// is safe and preserves the active-call delegate state.
/// - Parameter name: bundle filename without extension, e.g. "calls_calm".
/// Pass nil to use the system default CallKit ringtone.
private func applyRingtone(_ name: String?) {
let config = CXProviderConfiguration()
config.supportsVideo = provider.configuration.supportsVideo
config.maximumCallGroups = provider.configuration.maximumCallGroups
config.maximumCallsPerCallGroup = provider.configuration.maximumCallsPerCallGroup
config.supportedHandleTypes = provider.configuration.supportedHandleTypes
config.includesCallsInRecents = provider.configuration.includesCallsInRecents
config.iconTemplateImageData = provider.configuration.iconTemplateImageData
if let name {
config.ringtoneSound = name + ".mp3"
}
provider.configuration = config
}
// MARK: - Display-name selection
/// Pick the best name we can show on the initial CXCallUpdate, before
/// the ack-receipt has had a chance to enrich the payload. The order
/// favors the most-specific identifier we have: channel > sender > a
/// localized fallback. Returning empty would make CallKit display the
/// raw handle (a user id) — terrible UX.
private static func bestInitialDisplayName(_ request: IncomingCallRequest) -> String {
if !request.channelName.isEmpty { return request.channelName }
if !request.callerName.isEmpty { return request.callerName }
return localizedIncomingCallPlaceholder()
}
/// Reads `mobile.ios.calls.incoming_call_placeholder` from the main app
/// bundle's Localizable.strings
private static func localizedIncomingCallPlaceholder() -> String {
let key = "mobile.ios.calls.incoming_call_placeholder"
let appName = (Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
?? (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String)
?? "Mattermost"
let template = NSLocalizedString(key, bundle: .main, value: "\(appName) call", comment: "")
return template.replacingOccurrences(of: "{applicationName}", with: appName)
}
// MARK: - Bundled icon
private static func callKitIconTemplateData() -> Data? {
let frameworkBundle = Bundle(for: CallKitProvider.self)
guard let resourceURL = frameworkBundle.url(forResource: "MMCallsNativeResources", withExtension: "bundle"),
let resourceBundle = Bundle(url: resourceURL),
let image = UIImage(named: "MMCallKitIcon", in: resourceBundle, compatibleWith: nil) else {
GekidouLogger.shared.log(.warning, "CallKitProvider: MMCallKitIcon not found in resource bundle")
return nil
}
return image.pngData()
}
}