Перейти к содержимому

Разработка8 мин чтения

Три правила iOS для звонилок: VoIP-пуши, CallKit и AVAudioSession

Не зарепортили 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:

AppDelegate.swiftswift
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, для каждого пуша — в том числе для того, который мы не смогли разобрать, и для дубликата:

CallKitBridge.swiftswift
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 секунды:

CallKitBridge.swiftswift
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. Сбой пишется в лог, потому что без настроенной сессии звонок просто молчал бы, ничем больше себя не выдавая:

CallSystemIntegration.ios.ktkotlin
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 её видит:

GroupCallSystemEvents.ktkotlin
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, потому что в момент эскалации живут оба звонка. Своё аудио мы выключаем, только когда живых звонков не осталось:

AppDelegate.swiftswift
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) добавляет два недостающих вызова:

AppDelegate.swiftdiff
     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:

AppDelegate.swiftswift
/// [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 после режекта.
ПоделитьсяXLinkedIn

ONLYAPPS Team

Нови-Сад, Сербия · onlyapps.org

Контакты

Аналитические cookie мы используем только с вашего согласия. Необходимые cookie обеспечивают работу сайта. Политика конфиденциальности