Не зарепортили VoIP-пуш в CallKit — iOS убивает приложение. Ошиблись с аудио-сессией — звонок соединяется в тишине. Три правила iOS-слоя ID Caller и немые звонки, на которых мы их выучили.
ID Caller — наше приложение для приватных аудио- и видеозвонков без номера телефона (страница проекта). Почти весь клиент — общий Kotlin с UI на Compose Multiplatform. iOS-модуль — тонкий адаптер к PushKit, CallKit и аудио-сессии. Кода в нём немного, но именно здесь iOS строже всего проверяет звонковые приложения. Нарушите первое правило — система завершит приложение и может перестать доставлять ему VoIP-пуши. Нарушите два других — звонок соединится, но никто ничего не услышит.
Эти правила у нас записаны: первое — инвариант 5 в правилах проекта для агентов, два других — уроки полевых тестов. Для каждого разберём, что требует Apple и почему, что ломалось у нас и как устроен код сейчас.
Где живёт звонковый слой iOS
Вся логика звонков (машина состояний 1:1, групповой mesh, сигналинг) — в mobile/shared. На iOS между ней и системой стоят два Swift-файла. AppDelegate.swift отвечает за старт процесса и передачу аудио. CallKitBridge.swift держит логику PushKit и CallKit за протоколами, поэтому её можно тестировать без настоящего CXProvider и без Kotlin-фреймворка.
Порядок старта важен. iOS может разбудить убитое приложение VoIP-пушем, и в этом случае сцена не создаётся. Всё, что нужно обработчику пуша, должно быть готово в didFinishLaunching:
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// ...
// Убитое приложение, разбуженное VoIP-пушем, не создаёт сцену —
// Koin-граф обязан подняться здесь, иначе accept с CallKit мёртв.
MainViewControllerKt.ensureKoin()
// Manual audio: WebRTC не трогает аудио-юнит до явного enable
// (didActivate CallKit либо ответ/дозвон вне CallKit).
RTCAudioSession.sharedInstance().useManualAudio = true
let controller = CallKitController()
callKit = controller
// ...
let registry = PKPushRegistry(queue: .main)
registry.delegate = controller
registry.desiredPushTypes = [.voIP]
voipRegistry = registry
return true
}В руководстве Apple Responding to VoIP Notifications from PushKit тоже сказано создавать объект провайдера в самом начале жизни приложения. Наш создаётся внутри CallKitController, прямо здесь.
Правило 1: каждый VoIP-пуш становится звонком CallKit до completion
Что требует Apple: начиная с iOS 13 SDK VoIP-пуши PushKit — только для звонков. В pushRegistry(_:didReceiveIncomingPushWith:for:completion:) из PKPushRegistryDelegate приложение обязано сообщить о звонке в CallKit через reportNewIncomingCall. Если этого не сделать, система завершает приложение, а при повторных нарушениях может совсем перестать доставлять ему VoIP-пуши. Для звонилки это фактически бан: звонки на спящий телефон перестают приходить. Причина в том, что VoIP-пуш будит даже убитое приложение, и Apple даёт эту привилегию только в обмен на системный экран звонка. Если CallKit использовать нельзя, Apple предлагает обычные уведомления из фреймворка User Notifications вместо PushKit.
Наше правило строже, чем «зарепортить где-нибудь в обработчике». Репорт идёт синхронно, до completion, для каждого пуша — в том числе для того, который мы не смогли разобрать, и для дубликата:
func didReceiveVoipPush(_ payload: [AnyHashable: Any], completion: @escaping () -> Void) {
guard let push = IncomingCallPush.parse(payload) else {
reportFailedIncomingCall(callerName: "ID Caller")
completion()
return
}
guard !sessions.values.contains(where: { $0.callId == push.callId }) else {
// Даже дубликат обязан пройти reportNew до completion; сразу гасим
// синтетический звонок, чтобы не будить signaling повторно.
reportFailedIncomingCall(callerName: push.fallbackCallerName, handle: push.fromId, video: push.video)
completion()
return
}
let uuid = UUID()
sessions[uuid] = Session(callId: push.callId, push: push)
rememberCallKitCall(push.callId)
let update = CXCallUpdate()
// Имя из кэша сразу, Room — уточнение позже.
update.localizedCallerName = gateway.cachedCallerName(fromId: push.fromId) ?? push.fallbackCallerName
update.hasVideo = push.video
update.remoteHandle = CXHandle(type: .generic, value: push.fromId)
reporter?.reportNewIncomingCall(uuid: uuid, update: update) { _ in }
gateway.incomingCallPushed(push)
completion()
// Имя из Room — асинхронно, уже после обязательного репорта.
gateway.callerName(fromId: push.fromId) { [weak self] name in
// ... reportCall(uuid:updated:) с именем контакта
}
}reportFailedIncomingCall репортит звонок и сразу завершает его с .failed. Пользователь в худшем случае увидит короткую вспышку, а договор с PushKit соблюдён. Всё, что может быть медленным, — чтение имени контакта из локальной базы, подключение к сигналингу — идёт после репорта. SDP в пуше нет: он только будит WebSocket-сессию, а та забирает буферизованный оффер с сервера.
Это правило было риском R-01 в документах discovery ещё до того, как появился iOS-код. Тикет, в котором строился мост PushKit, покрыл его XCTest-ами, и сейчас testReportsCallKitImmediately, testMalformedPayloadStillReportsBeforeCompletion и testDuplicatePushStillReportsBeforeCompletion проверяют порядок «сначала reportNew, потом completion» на записывающем фейке CXProvider. Ещё один тест читает Info.plist и падает, если в UIBackgroundModes нет voip или audio.
Правило 2: звук живёт у звонка, о котором знает CallKit
Что требует Apple: для известных ему звонков AVAudioSession принадлежит CallKit. Вы настраиваете категорию и выполняете (fulfill) действие ответа или исходящего звонка. Затем система активирует сессию и сообщает об этом в provider(_:didActivate:) из CXProviderDelegate, и только после этого стоит запускать медиа. Когда заканчивается последний звонок, о котором знает CallKit, он деактивирует сессию — что бы ещё ни играло через неё в вашем приложении. Обе половины этого правила мы выучили на собственных ошибках.
Категория до fulfill
5 июля мы проводили полевой тест с убитым iPhone. Пуш пришёл, WebSocket подключился, телефон зазвонил примерно за секунду. Пользователь ответил с экрана блокировки, а didActivate так и не пришёл: без заранее настроенной категории CallKit не активировал сессию. В исправлении (16f20c5) категория ставится в действии ответа до fulfill(), а страховка форсирует принятие, если didActivate не пришёл за 3 секунды:
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
guard sessions[action.callUUID] != nil else {
action.fail()
return
}
// Категория обязана быть настроена ДО fulfill — иначе CallKit не
// активирует аудио-сессию и didActivate не приходит.
gateway.prepareAudioSession()
sessions[action.callUUID]?.answering = true
pendingAnswers.insert(action.callUUID)
action.fulfill()
// Страховка: didActivate иногда не приходит — форсим accept, аудио
// включаем вручную (manual mode).
let uuid = action.callUUID
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
guard let self, self.pendingAnswers.contains(uuid) else { return }
self.activatePendingAnswers()
}
}Саму категорию ставит общий Kotlin через биндинги AVFoundation в Kotlin/Native. Сбой пишется в лог, потому что без настроенной сессии звонок просто молчал бы, ничем больше себя не выдавая:
private fun configureCallAudioSession() {
val applied = AVAudioSession.sharedInstance().setCategory(
category = AVAudioSessionCategoryPlayAndRecord,
mode = AVAudioSessionModeVoiceChat,
options = AVAudioSessionCategoryOptionAllowBluetooth,
error = null,
)
if (!applied) {
// Без настроенной сессии CallKit не пришлёт didActivate и звонок
// останется немым — сбой обязан быть виден в диагностике.
idcLog("[idc-ck] audio session setCategory failed")
}
}Групповой звонок, о котором CallKit не знал
9 июля звонок 1:1 эскалировали в групповой (mesh). Группа жила вне CallKit. Когда старый звонок 1:1 завершился, CallKit закрыл единственный известный ему звонок и деактивировал аудио-сессию. По логам Android-телефона mesh поднимался идеально, но оба iPhone молчали.
В исправлении (5a71acd) группа эмитит те же системные события, что и звонок 1:1, и CallKit её видит:
fun groupCallSystemEvents(states: Flow<GroupCallState>): Flow<CallSystemEvent> = flow {
var previous: GroupCallState = GroupCallState.Idle
// ...
states.collect { next ->
val prevActive = previous as? GroupCallState.Active
when {
next is GroupCallState.Active && prevActive == null -> {
// ...
val ringing = previous as? GroupCallState.InviteRinging
if (ringing?.callId == next.callId) {
emit(CallSystemEvent.AnsweredLocally(next.callId))
} else {
val handle = next.members.firstOrNull()?.id ?: next.initiatorId
emit(CallSystemEvent.OutgoingStarted(next.callId, handle, false))
}
}
next !is GroupCallState.Active && prevActive != null ->
emit(CallSystemEvent.CallFinished(prevActive.callId))
// ...
}
// ...
previous = next
}
}Оба платформенных моста слушают один объединённый поток событий 1:1 и группы. На Android был баг того же рода: мост слушал только звонки 1:1, группа не регистрировалась в Telecom, и её звук шёл медиапотоком через громкоговоритель. На стороне Swift провайдер допускает maximumCallGroups = 2, потому что в момент эскалации живут оба звонка. Своё аудио мы выключаем, только когда живых звонков не осталось:
CallKitSyncBridge.shared.onCallFinished = { [weak controller] callId in
DispatchQueue.main.async {
let callKitOwned = controller?.isRegistered(callId: callId) ?? false
controller?.callFinished(callId: callId)
// Эскалация 1-на-1 → группа: завершение старого звонка НЕ
// должно глушить аудио живой группы.
if controller?.hasActiveCalls != true {
AudioSessionHandoff.disable(deactivateSession: !callKitOwned)
}
}
}Правило 3: вне CallKit активируйте сессию сами — целиком
WebRTC работает в ручном режиме (useManualAudio = true), поэтому его аудио-юнит выключен, пока мы его не включим. Иначе WebRTC может запустить звук раньше, чем CallKit активирует сессию, и получится плавающая тишина. Обычно сигнал на включение — didActivate.
Но у части звонков didActivate не бывает. Сервер шлёт VoIP-пуш на group.invite только тем приглашённым, кто офлайн. Приглашённый онлайн получает инвайт по WebSocket, видит наш собственный экран входящего на Compose и жмёт «Принять» в приложении. Звонка в CallKit нет — значит, нет и didActivate. То же с исходящим, если CallKit отклонил CXStartCallAction. В этих случаях AppDelegate сам вызывает AudioSessionHandoff.enable().
Немой ответ из приложения
24 июля мы тестировали на iPhone 14 Pro Max. Звук 1:1 прошёл на всех путях, но групповой звонок, принятый кнопкой в приложении, был без звука. enable() сообщал WebRTC, что сессия активна, и включал его аудио. Но категорию никто не ставил и setActive(true) никто не вызывал, так что WebRTC «активировал» мёртвую сессию. Исправление (b25dfff) добавляет два недостающих вызова:
static func enable() {
guard !active else { return }
active = true
+ let av = AVAudioSession.sharedInstance()
+ // Не-CallKit приём (кнопка в Compose-UI): категорию и активацию
+ // сессии никто не делал — audioSessionDidActivate «активировал»
+ // мёртвую сессию. CallKit-путь повторяет эти вызовы идемпотентно.
+ try? av.setCategory(.playAndRecord, mode: .voiceChat, options: [.allowBluetooth])
+ try? av.setActive(true)
let rtc = RTCAudioSession.sharedInstance()
- rtc.audioSessionDidActivate(AVAudioSession.sharedInstance())
+ rtc.audioSessionDidActivate(av)
rtc.isAudioEnabled = true
}Флаг active делает enable() идемпотентным. На пути через CallKit его тоже вызывает didActivate. Там категория уже стоит, а сессию уже активировала система, так что повторные вызовы ничему не вредят.
Отпускайте только то, что активировали сами
Тот же коммит заставил disable() каждый раз вызывать setActive(false). В комментарии было написано, что после звонка через CallKit этот повтор ничего не делает. 24 сентября выяснилось, что это не так. Когда мы деактивировали сессию, которой владел CallKit, следующий исходящий в том же процессе оставался без didActivate: переключение на громкий динамик не срабатывало, и гудки видео-набора шли в ухо. Теперь мост запоминает, какие звонки прошли через CallKit, и сессию сами отпускают только звонки вне CallKit:
/// [deactivateSession] — только для звонка вне CallKit: сессию, которой
/// владеет CallKit, деактивирует система в didDeactivate. Наш
/// setActive(false) на ней ломал CallKit для следующего исходящего.
static func disable(deactivateSession: Bool) {
guard active else { return }
active = false
queue.async {
let rtc = RTCAudioSession.sharedInstance()
rtc.isAudioEnabled = false
rtc.audioSessionDidDeactivate(AVAudioSession.sharedInstance())
if deactivateSession {
try? AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
}
}
}queue — отдельное исправление (6b45c7a). Активация сессии и запуск аудио-юнита WebRTC занимали на главном потоке около 0,3 с, и HangTracer на устройстве это отметил. Теперь оба шага выполняются на серийной очереди. Она сохраняет порядок enable и disable, а флаг active остаётся на главном потоке.
Чеклист
- Создавайте
PKPushRegistryиCXProviderвdidFinishLaunchingи там же поднимайте всё, от чего зависит обработчик пуша. При пробуждении VoIP-пушем сцена не создаётся. - Репортите каждый VoIP-пуш через
reportNewIncomingCallсинхронно, доcompletion. Это касается и мусорных payload, и дубликатов: зарепортите и сразу завершите с.failed. - Медленную работу делайте после репорта, а звонок обновляйте через
reportCall(with:updated:). - Проверяйте порядок тестом на записывающем фейке
CXProvider, аUIBackgroundModes— на наличиеvoipиaudio. - Ставьте категорию
AVAudioSessionдоfulfill()уCXAnswerCallActionи держите страховку на случай, еслиdidActivateне придёт. - Любой вид звонка, который играет звук, должен быть известен CallKit, включая группы и эскалации. Подберите
maximumCallGroupsпод переходный момент. - Никогда не вызывайте
setActive(false)на сессии, которой владеет CallKit. Своё аудио выключайте, только когда живых звонков не осталось. - Используйте ручной режим
RTCAudioSessionи включайте звук WebRTC, только когда сессия действительно активна. - На путях без CallKit делайте все три вызова сами:
setCategory,setActive(true), затемaudioSessionDidActivateсisAudioEnabled. - Держите
setActiveвне главного потока, на серийной очереди. - Для App Review: VoIP-пуши PushKit и CallKit не зависят от разрешения на уведомления, поэтому не закрывайте вход в приложение за этим разрешением. Это стало нашим главным аргументом в ответе Apple после режекта.
ONLYAPPS Team
Нови-Сад, Сербия · onlyapps.org