mirror of
https://github.com/mattermost/mattermost-mobile.git
synced 2026-11-05 02:18:56 +00:00
* 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
412 lines
18 KiB
Swift
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()
|
|
}
|
|
}
|