Занимаюсь разработкой под iOS с 2012 года. С SIP ранее не работал, и его интеграция оказалась куда сложнее, чем ожидалось. Ниже делюсь основными трудностями и решениями.

Надеюсь получить конструктивную критику, а также советы по альтернативным подходам к решению описанных проблем.

Итак, задача: в кратчайшие сроки реализовать функциональность приёма звонков и набора номера для вызова на домофон.

Первым этапом стало изучение доступных на рынке SIP-библиотек. Наибольшее распространение получили Linphone и PJSIP.

Ниже — краткое сравнение двух наиболее популярных библиотек.

Функционал

Linphone

PJSIP

Простота интеграции

Проще (особенно для начинающих)

Сложнее, но гибче

? Поддержка аудио-кодеков

Да (Opus, G722, Speex и др.)

Да

? SRTP / ZRTP

Да (безопасные звонки)

Да (но ZRTP вручную)

? Документация

Умеренная, примеры в SDK

Хорошая, но более низкоуровневая

? Когда стоит использовать Linphone:

  • Нужно быстро встроить SIP-звонки в iOS-приложение.

  • Требуется видеозвонок и готовые UI-компоненты.

  • Нет желания глубоко разбираться в VoIP-протоколах.

  • Нужна кроссплатформенность (поддерживаются Android, iOS и десктоп).

? Когда лучше выбрать PJSIP:

  • Необходима максимальная гибкость и контроль (например, сложная логика маршрутизации вызовов, кастомный транспорт).

  • Важно минимизировать размер библиотеки.

  • Уже есть опыт работы с PJSIP или готовая инфраструктура на его основе.

Если нужен готовый SIP-клиент с возможностью кастомизации, Linphone — вполне подходящий вариант.

Если же используется собственная VoIP-система со специфическими требованиями — возможно, лучше использовать на PJSIP.

Из-за сжатых сроков и отсутствия опыта с SIP выбор пал на Linphone.

? А что по лицензии у Linphone?

Тут стоит обратить внимание на лицензионные нюансы.
Linphone SDK (liblinphone) распространяется под лицензией GPLv2/v3, которая требует раскрытия всего производного кода. Согласно условиям лицензии:

«You must make available the complete corresponding source code of the work under the same license.»

Это означает, что необходимо раскрыть не только сам SDK, но и весь код, который взаимодействует с ним напрямую или через API/динамическую линковку (если это не отдельное standalone-приложение).

Для некоммерческого использования это не критично.
Однако, при использовании в корпоративной среде необходимо внимательно изучить условия лицензии и, скорее всего, приобрести коммерческую лицензию у Belledonne Communications.

Подключение библиотеки Linphone к проекту

Проект, к которому планировалось подключить Linphone, использовал CocoaPods, поэтому логичным шагом было добавить актуальную на тот момент версию библиотеки — 5.4.10. Однако при запуске приложения возникла ошибка:

'/Users/admin/Library/Developer/Xcode/DerivedData/Airkey‑bjqghwoaoqpanieccdctwipgfnhf/Build/Products/Debug‑iphonesimulator/liblibbelle‑sip‑tester.dylib' (no such file), '/Library/Developer/CoreSimulator/Volumes/iOS_21F79/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS 17.5.simruntime/Contents/Resources/RuntimeRoot/usr/lib/system/introspection/liblibbelle‑sip‑tester.dylib' (no such file),

На момент написания статьи существовала известная проблема с установкой зависимостей через CocoaPods из удалённого репозитория Linphone. Она остаётся нерешённой уже длительное время и может вызывать ошибки сборки и запуска на последних версиях библиотек.

В связи с этим было принято временное решение откатиться на версию linphone-sdk 5.3.94, которая показала более стабильную работу при подключении к проекту.

Также важно учитывать, что для корректной установки зависимостей требуется вручную указать альтернативный источник pod-репозиториев в Podfile. Для этого необходимо добавить строку:

source 'https://gitlab.linphone.org/BC/public/podspec.git'

Это связано с тем, что linphone-sdk использует собственный pod-репозиторий, где размещены все необходимые спецификации (.podspec) для корректной установки зависимостей.

Однако, если всё же требуется использовать актуальные версии библиотек и при этом остаться на CocoaPods (несмотря на то, что Cocoapods уже не рекомендуется использовать в проекте), можно воспользоваться следующей последовательностью команд:

# Клонируем библиотеку
git clone https://gitlab.linphone.org/BC/public/linphone-sdk.git
# Заходим в папку
cd linphone-sdk

# Скачиваем все зависимости.
git pull
git submodule update --init –recursive

# Устанавливаем окружение для Python:
# Сначала обновим его

python3 -m pip install --upgrade pip

# Создадим и активируем виртуальное окружение:
python3 -m venv venv
source venv/bin/activate

# Установим необходимые зависимости для модуля питона:
pip install pystache six
# Проверяем что все установлено правильно: 
pip list

На последнем этапе конфигурируем библиотеку через cmake команду:

cmake --preset=ios-sdk -G Ninja -B sdk -DENABLE_PQCRYPTO=YES -DLINPHONESDK_IOS_ARCHS=arm64 -DENABLE_NON_FREE_FEATURES=YES -DENABLE_GPL_THIRD_PARTIES=YES -DENABLE_G729=YES -DCMAKE_CONFIGURATION_TYPES=ReleaseWithDebInfo

И финальная сборка на ~ 3Gb (нужные библиотеки можно ужать в 600 мб)
cmake --build sdk --config RelWithDebInfo -j5

cmake --preset=ios-sdk \                     
  -G Xcode \
  -B sdk \
  -DENABLE_PQCRYPTO=YES \
  -DLINPHONESDK_IOS_ARCHS=arm64 \
  -DENABLE_NON_FREE_FEATURES=YES \
  -DENABLE_GPL_THIRD_PARTIES=YES \
  -DENABLE_G729=YES \
  -DCMAKE_CONFIGURATION_TYPES=ReleaseWithDebInfo \
  -DCODE_SIGN_IDENTITY="" \
  -DCMAKE_XCODE_ATTRIBUTE_DSYM_ENABLED=YES

cmake --build sdk --config RelWithDebInfo -j5  

Для генерации dSYM-файлов мне пришлось внести небольшие изменения в файл GenerateFrameworks.cmake, чтобы они создавались на этапе компиляции и автоматически копировались в нужную папку.

В противном случае при публикации приложения в App Store можно столкнуться со следующим сообщением:

Предупреждения при публикации билда в TestFlight
Предупреждения при публикации билда в TestFlight


С переходом большинства проектов на Swift Package Manager (SPM) работа с зависимостями стала значительно проще. Однако в моём случае периодически возникала необходимость очищать сборку, иначе пакет linphonesw просто не определялся. Возможно, в более свежих версиях эта проблема уже устранена.

Настройка SDK

Наконец-то SDK успешно интегрирован в проект и ничего не ломается. Переходим к настройке.

Часть работы SIP, касающуюся настройки FreePBX, я пропущу — всё-таки статья посвящена именно мобильному клиенту.

Сначала включается подробный лог, чтобы видеть, что происходит внутри Linphone — удобно для отладки. Затем на всякий случай останавливается ядро, если оно уже было запущено. Затем подключаем конфигурационные файлы (linphonerc_default и linphonerc_factory) и выполняем регистрацию на SIP-сервере.
Дальше формируются пути к конфигурационным файлам: основной будет храниться в библиотеке приложения, а заводской берётся из ресурсов (бандла).
После этого создаётся само ядро Linphone с этими конфигами. Если указан STUN-сервер, то разбирается его адрес и настраивается NAT-политика — в этом случае просто всё отключается (ICE, STUN, TURN), чтобы не мешало.
Затем включаются нужные фичи: CallKit (если нужно), видеозвонки, адаптивное управление качеством. Пуши, используемые в библиотеке решено было отключить, так как не для всех сценариев их использования подходило то, как они реализованы в Linphone.
Когда всё готово — ядро запускается.
Напоследок очищаются все старые аккаунты и данные авторизации, чтобы начать с чистого листа:

linphonesw.LoggingService.Instance.logLevel = .Debug
        stop()
        do {
            let configName = "linphonerc_default"
            let factoryName = "linphonerc_factory"
            
            guard let configTarget = FileManager.default
                .urls(for: .libraryDirectory, in: .userDomainMask)
                .first?
                .appendingPathComponent(configName) else {
                fatalError("Unable to write config file to library")
            }
            
            //Инициализируем ядро            
            core = try Factory.Instance.createCore(
                configPath: configTarget.relativePath,
                factoryConfigPath: Bundle.main.path(forResource: factoryName, ofType: "") ?? "",
                systemContext: nil
            )
            
            
            if let core = core {
                let stun = config.stun ?? "none:"
                let params = stun.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
                let typeString = String(params[0])
                let serverString = String(params[1])
            
                let nat = try? core.natPolicy ?? core.createNatPolicy()
                if let natPolicy = nat {
                        natPolicy.iceEnabled = false
                        natPolicy.stunEnabled = false
                        natPolicy.turnEnabled = false
                    core.natPolicy = natPolicy
                }
            
                core.callkitEnabled = config.useCallKit
                core.pushNotificationEnabled = false
                core.videoDisplayEnabled = true
                core.adaptiveRateControlEnabled = true
                core.addDelegate(delegate: self)

                try core.start()

                core.clearAllAuthInfo()
                core.clearAccounts()

После запуска ядра необходимо запустить таймер, который будет вызывать функцию iterate() c определенной частотой:

                timer = Timer.scheduledTimer(withTimeInterval: 0.02, repeats: true) { [weak self] _ in
                    guard let self = self else { return }

                    if let call = self.core?.currentCall {
                        let state = call.state
                        let status = call.callLog?.status
                        if state == .Error || state == .End || state == .Released || status == .AcceptedElsewhere {
                            // Не итерируем, если вызов завершен с ошибкой
                            LegacyLoggingAgent.log(.debug, message: "⛔️ Sip Skipping iterate: Call is in state \(state)")
                            return
                        }
                    }

                    self.core?.iterate()
                }
            }

Функция iterate() в Linphone — это движок, который крутит внутренний цикл событий. Она особенно важна, если вы не используете встроенный поток Linphone и всё обрабатываете вручную.

Функция iterate() отвечает за:

  • входящие/исходящие звонки и регистрацию,

  • обработку RTP/SDP пакетов,

  • обновление состояния (онлайн/офлайн, активные вызовы),

  • поддержку таймеров и соединений.

Обычно iterate() вызывают через таймер с небольшим интервалом (например, раз в 20-50 мс). Но с ней нужно быть аккуратным — если вызвать в неподходящий момент, приложение может упасть.

Часто сбои происходят из-за непредвиденных ситуаций во время работы. Поэтому, если есть сомнения — лучше временно "поставить на паузу" вызов iterate(), чтобы не нарваться на краши или странное поведение.

Конфигурация SIP аккаунта

Чтобы Linphone начал принимать и делать звонки, нужно правильно настроить SIP-аккаунт — без этого ничего не заработает.

Для этого используется метод setAccountConfiguration. Он берёт данные пользователя (логин, домен, транспорт) и на их основе инициализирует SIP-аккаунт внутри Linphone. Основной шаг, с которого начинается вся магия VoIP-подключения. Без него не будет ни регистрации, ни звонков.

extension AccountParams {
    func setAccountConfiguration(core: Core, configuration config: SipConfig) {
        guard let identityAddress = core.interpretUrl(
            url: "sip:\(config.username)@\(config.domain)",
            applyInternationalPrefix: false
        ),
              let proxyAddress = core.interpretUrl(
                url: "<sip:\(config.domain);transport=\(config.transport)>",
                applyInternationalPrefix: false
              )
        else {
            return
        }
        do {
            try identityAddress.setTransport(newValue: config.transport)
            try setIdentityaddress(newValue: identityAddress)
            try setServeraddress(newValue: proxyAddress)
          }
      }
            registerEnabled = true
            guard let account = try? core.createAccount(params: self) else {
                return
            }
            try core.addAccount(account: account)
            core.defaultAccount = account
        } catch {
            log("Error: \(error)")
        }
    }
}

Зарегистрированное соединение поддерживается в течение определённого времени. Если соединения закрывать некорректно и превышать допустимое количество активных регистраций, может возникнуть ситуация, при которой регистрация нового соединения станет невозможной. Поэтому рекомендуется поддерживать адекватное время подключения.
Для этих целей можно использовать, глобальную переменную библиотеки expires, лучше установить ее в значение, например, 60 секунд.

Давайте посмотрим, как работает процесс создания звонка в приложении с помощью функции makeCall().
Эта функция — это как "вызов по кнопке", только реализованный в коде. Она отвечает за всё: от проверки, что вы в сети и готовы к звонку, до запуска самого вызова.

func makeCall() {
# Сам звонок 

            let startTime = Date()
            let timeout: TimeInterval = 3
# Для демонстрации показано как происходит ожидание, а так через Mutex и NSLock
            while LinphoneService.shared.core?.defaultAccount?.state != .Ok {
                usleep(100000)
                if -startTime.timeIntervalSinceNow > timeout {
                    LegacyLoggingAgent.log(.error, message: "SIP sip registration was failed \(timeout) seconds")
                    return
                }
            }

        let username = “Формируем username”

        let addr = "sip:\(username)@\(domain)"

        guard let core = core else { return }

        do {
            // ? ВАЖНО: сначала активируем аудио
            let audioSession = AVAudioSession.sharedInstance()
            try audioSession.setCategory(.playAndRecord, mode: .voiceChat, options: [.defaultToSpeaker])
            try audioSession.setActive(true)
            core.activateAudioSession(activated: true)

            // ? Настраиваем адрес вызова
            let remoteAddress = try Factory.Instance.createAddress(addr: addr)
            try remoteAddress.setPort(newValue: 5060)
            try remoteAddress.setDomain(newValue: domain)
            try remoteAddress.setDisplayname(newValue: username)

            // ✅ Настройка параметров звонка
            let callParams = try core.createCallParams(call: nil)
            callParams.audioEnabled = true
            callParams.videoEnabled = true // если нужно видео
            let call = core.inviteAddressWithParams(addr: remoteAddress, params: callParams)
            call?.cameraEnabled = false

        } catch {
            log("❌ Ошибка при попытке вызова: \(error)")
        }
    }   
}

Вначале программа проверяет, зарегистрирован ли ваш SIP-аккаунт (эсервер настроен таким образом, что для осуществления вызовов необходима регистрация). Ждём до 3 секунд — если за это время подключения не произошло, просто выходим и записываем в лог, что что-то пошло не так.

Если всё ок, формируется адрес абонента, которому хотим позвонить — например, sip:user@example.com.
Дальше начинается подготовка к звонку:

  • Включаем звук — настраиваем микрофон и динамик, чтобы вы могли говорить и слышать собеседника. Без этого звонок бы пошёл, но звука не было бы.

  • Собираем адрес, куда будем звонить — указываем порт, домен и имя.

  • Устанавливаем параметры звонка — например, нужен ли только голос или ещё и видео.

  • Запускаем сам звонок — вызываем специальную функцию, которая его инициирует.

  • Если не нужен видеозвонок, отключаем камеру.

Если в процессе что-то пойдёт не так (например, не получилось включить микрофон или собрать параметры вызова), ловим ошибку и пишем об этом в лог — чтобы было понятно, где проблема.

Push уведомления

При приёме вызова (особенно в фоновом режиме) необходимо корректно принять звонок по push-уведомлению и обработать звонок как на заблокированном экране, так и внутри приложения.
Упрощенно процесс вызова удаленным абонентом устроен следующим образом:

  • Удаленный абонент осуществляет SIP-вызов на SIP-сервер (в нашем случае это FreePBX).

  • Диалплан внутри FreePBX настроен таким образом, что в определнный момент выполнения инициирует синхронный AGI-запрос в инфраструктуру приложения и ждет ответа. • Инфраструктура (непосредственно, её бэкенд-часть) отправляет VoIP-пуш-уведомление через APNS в приложение.

  • Приложение, получив пуш, поднимается (если остановлено или находится в фоне), запускает linphone SDK и регистрируется на SIP-сервере, после чего рапортует об этом на бэкенд.

  • Бэкенд в свою очередь отвечает на AGI-запрос положительно, после чего выполнение диалплана продолжается и происходит непосредственно SIP-вызов в приложение.

  • Если в процессе возникли какие-то проблемы (например, бэкенду не удалось связаться с приложением — получить за установленное время положительный ответ от том, что оно готово принять звонок), то подается отрицательный ответ на ждущий AGI-запрос, и SIP-сервер уведомляет о невозможности осуществить вызов, после чего диалплан завершается.

  • Если всё прошло успешно, приложение, получив вызов,, создаёт CallKit UI (reportNewIncomingCall) — пользователь видит обычный экран звонка.

  • Если пользователь отвечает — CallKit даёт команду performAnswerCall, и приложение сообщает об этом Linphone SDK.

  • Linphone SDK принимает SIP-звонок (call.accept()), и устанавливается аудио-соединение между абонентами.

Схема звонка с домофона на телефон
Схема звонка с домофона на телефон

Данная схема позволяет не держать постоянно открытые подключения и регистрации с SIP-сервером, но при этом дает возможность так же оперативно реагировать на входящие звонки, за счет чего осуществляется существенная экономия ресурсов устройства.

provider(_:perform:) — она вызывается CallKit'ом, когда пользователь нажимает «Ответить» на входящий звонок:
Эта функция — "мостик" между CallKit и Linphone. Когда пользователь отвечает на звонок через стандартный экран iOS, именно здесь приложение:

  • ждёт готовности,

  • настраивает звонок,

  • принимает его через Linphone,

  • и сообщает системе, что всё ок.

  1. Асинхронно уходит в фоновый поток:

    • Это нужно, чтобы не блокировать основной поток (UI), особенно если звонок ещё не готов.

  2. Ожидает появления currentCall:

    • В цикле while проверяется, есть ли объект текущего звонка. Пока его нет — ждёт (пауза 100 мс). Это важно, потому что входящий звонок может быть ещё в процессе обработки.

  3. Сохраняет действие CallKit для дальнейшего использования:

    • pendingAcceptAction = action — чтобы не потерять контекст CallKit, пока всё готовится.

  4. Через 0.5 секунды на главной очереди (UI-потоке):

    • Даёт ядру немного времени «успокоиться», прежде чем принять звонок (стабильнее так).

  5. Проверка, что звонок и ядро (core) действительно существуют:

    • Если нет — просто сохраняем action, чтобы потом снова попытаться, и выходим.

  6. Отключает камеру:

    • Для входящего голосового вызова она не нужна.

  7. Создаёт параметры вызова:

    • Включает только аудио.

  8. Принимает звонок:

    • Через call.acceptWithParams(...) начинается соединение.

  9. Уведомляет CallKit, что звонок принят:

    • action.fulfill() — система теперь знает, что всё прошло успешно.

  10. Вызывает showUI():

    • Скорее всего, это твоя функция, открывающая интерфейс звонка.

? Обработка ошибок:

Если что-то пошло не так (например, не удалось принять звонок):

  • Вызов action.fail() сообщает системе, что звонок не удался.

  • Затем вызывается terminateCall() — завершает звонок внутри приложения.

  • Логируется ошибка.

  • Ядро Linphone останавливается.

  • assert(false) — жёсткая остановка приложения в отладочном режиме (может быть полезна в dev-сборках, но в релизе её лучше убрать).


  • Часто в логах сыпятся ошибки, которые происходят при неправильной или неполной конфигурации SIP-аккаунта или сети в Linphone. Ниже я разберу каждую ошибку и подскажу возможные причины и пути решения:


2025-04-22 23:18:09:665 liblinphone-error- There is no NatPolicy with ref [wA0ylyNsKiMdCbd]
2025-04-22 23:18:09:668 liblinphone-warning- Bad account address: it is not in the list !
2025-04-22 23:18:09:673 mediastreamer-warning- Could not apply gain on sent RTP packets: gain control wasn't activated. Use audio_stream_enable_gain_control() before starting the stream.
2025-04-22 23:18:09:673 ortp-warning- Fail to set IPv4 packet info on RTP socket: Invalid argument.
2025-04-22 23:18:09:673 ortp-warning- Fail to set IPv4 packet info on RTCP socket: Invalid argument. 2025-04-22 23:18:09:673 liblinphone-error- Unable to retrieve contact address from account for call session 0x119335748 (local address remote address sip:200@xxx.xx.xx.xx).
2025-04-22 23:18:09:673 liblinphone-warning- Unable to set contact address for session 0x118d65880 to as it is not valid
2025-04-22 23:18:09:673 belle-sip-error- No listening point matching for [Udp://xxx.xx.xx.xx:5060] 2025-04-22 23:18:09:673 belle-sip-error- belle_sip_client_transaction_send_request(): no channel available

Что говорят ошибки:

? No NatPolicy with ref [...]

❗ Это предупреждение, что ты не настроил NAT policy (например, STUN).
Не критично, если сервер доступен напрямую, но лучше задать:

natPolicy.stunServer = "stun.linphone.org"
natPolicy.iceEnabled = true
natPolicy.upnpEnabled = false
natPolicy.turnEnabled = false
core.natPolicy = natPolicy

❗ Bad account address: it is not in the list!

Аккаунт sip:100@xxx.xx.xx.xx не зарегистрировался корректно. Это может быть из-за:

  • Неправильно сформированного SIP URI

  • Проблем с транспортом (TCP/UDP)

  • Отсутствия listening point (это ключевое!)

? No listening point matching for [Udp://xxx.xx.xx.xx:5060]

❗ Самое критичное: Linphone не создал UDP-транспорт, чтобы слушать порт 5060.

? Причина:

Linphone не знает, на каком интерфейсе открывать сокеты.

Ошибки с аудио на старте — не критичны.

Если видишь в логах что-то вроде:

Can't find sound device with id AU: Audio Unit Receiver

это не конец света. Просто Linphone не нашёл устройство вывода/ввода звука (а ты его не указал вручную). На старте разработки — можно проигнорировать. Но если в звонке нет звука вообще, стоит задать аудио устройство явно:

core.audioDevice = core.audioDevices.first { $0.type == .builtin }

А ещё не забудь про AVAudioSession:

try? AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .voiceChat)
try? AVAudioSession.sharedInstance().setActive(true)

? LIME ругается? Да и ладно

[LIME] No LIME server URL in account params

Это просто предупреждение: ты не указал сервер для зашифрованной переписки (LIME). Если ты не используешь чат — можешь вообще не обращать внимания. Но если планируешь обмен сообщениями — пропиши limeServerUrl вручную:

accountParams.limeServerUrl = "https://lime.linphone.org/lime"

Cвязка Linphone + CallKit:

Когда ты хочешь реализовать VoIP-звонки в iOS-приложении «как у людей», с нормальным системным интерфейсом, тебе не обойтись без CallKit. Это тот самый фреймворк, который показывает стандартный экран входящего звонка — такой же, как у обычного телефонного вызова.

Фишка в том, что в iOS нельзя просто так принять звонок, если приложение в фоне. Даже если тебе прилетит VoIP push и ты обработаешь его, без CallKit ты не сможешь разбудить интерфейс и показать, что звонит абонент. Просто всплывающее уведомление — это не звонок. А вот CallKit позволяет:

  • показывает системный экран звонка (как у встроенной телефонии);

  • обрабатывает кнопки «Ответить» и «Отклонить»;

  • поддерживает Bluetooth-гарнитуры и режим «Не беспокоить»;

  • корректно интегрируется с VoIP push-уведомлениями.

Без CallKit iOS не воспримет VoIP push как звонок и может завершить приложение, решив, что ты что-то мутное затеял (см. [тот самый NSInternalInconsistencyException] ниже).

CallKit — это обязательный мост между твоим VoIP-движком (например, Linphone) и iOS. Он отвечает за внешний вид звонка, взаимодействие с пользователем и вообще за то, чтобы Apple разрешила тебе «играть в телефонию».

Так что, если ты разрабатываешь звонки — хоть SIP, хоть WebRTC — первым делом подумай, как ты будешь связывать их с CallKit. Иначе в фоне у тебя просто ничего не заработает.

Вот как всё обычно устроено:

  • LinphoneCore ловит входящий SIP-звонок (например, после push).

  • Ты создаёшь CXProvider и через него репортишь звонок в CallKit (reportNewIncomingCall).

  • Пользователь видит стандартный экран звонка (как у обычного iPhone-звонка) и может ответить.

  • Когда он отвечает — прилетает callback performAnswerCall, и ты просто пробрасываешь управление обратно в Linphone:

    call?.accept()

⚠️ Важные нюансы:

  • Обязательно включи VoIP background mode в Capabilities. Напоролся на этот нюанс так, что функционал звонка вообще нормально не работал.

  • CallKit полноценно работает только на реальных устройствах — в симуляторе будет очень ограниченно работать.

  • Нужны разрешения на микрофон (иначе звука не будет) и желательно уведомления.

  • Отлично работает в паре с PushKit — для VoIP-пушей (тех, что действительно "будят" приложение).

❗️Про CXProviderConfiguration(localizedName:)

Да, этот инициализатор с localizedName: с iOS 14 отмечен как deprecated:

let config = CXProviderConfiguration(localizedName: "MyApp") // deprecated, говорят...

Но это не баг, а, судя по всему, троллинг от Apple: альтернативы просто нет, и даже Apple в своих примерах продолжает его использовать. Так что:

? Пока не появится что-то новое — продолжаем использовать как есть, спокойно и с чистой совестью.

? Проблемы с push / сертификатами

Одна из типичных грабель: мы зачем-то сгенерировали voip сертификат, распаковали .p12 в .p8, думая, что всё будет работать. Но для VoIP-пушей можно использовать тот же сертификат что для обычных пушей, только apns-topic записывается как bundleId.voip а apns-push-type: voip.

Чем жеAPNs VoIP-push'и отличаются от обычных:

  • Могут разбудить приложение, даже если оно прибито.

  • Доставляются быстрее и надёжнее.

  • Требуют специальный тип пуша — apns-push-type: voip.

? Как тестировать VoIP push'и?

Apple даёт удобную веб-консоль, где можно отправлять тестовые push-уведомления вручную:

  • https://push.apple.com (или через curl, если хочется по-брутальному)

  • Убедись, что в payload есть apns-push-type: voip, иначе звонок не поднимется.

VoIP пуши + Background = зона особого внимания

Когда ты используешь PushKit для приёма VoIP-пушей, и приложение находится в фоне (или вообще прибито), iOS ждёт от тебя одного:

? Вызови reportNewIncomingCall() в CallKit как можно быстрее.

Если ты этого не сделаешь в течение нескольких секунд, то iOS считает, что ты "не умеешь" обрабатывать VoIP-пуши — и жёстко убивает приложение. Причём не просто завершает, а отключает возможность получать VoIP-пуши вообще, пока ты не:

  • Удалишь и переустановишь приложение

  • или не перезагрузишь устройство (да, серьёзно)

? Что ты увидишь в логах:

*** Assertion failure in -[PKPushRegistry _terminateAppIfThereAreUnhandledVoIPPushes], PKPushRegistry.m:349
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Killing app because it never posted an incoming call to the system after receiving a PushKit VoIP push.'
*** First throw call stack:
(0x18d6152ec 0x18aa99a7c 0x18c911ea0 0x21c386494 0x105fa6064 0x105f9d19c 0x21c3856fc 0x105f8c584 0x105fa6064 0x105fc6f38 0x105f9c548 0x105f9c484 0x18d56e2b4 0x18d56c0b0 0x18d590700 0x1da0d1190 0x1901ae240 0x1901ac470 0x106e6862c 0x1b3f93ad8)
libc++abi: terminating due to uncaught exception of type NSException
*** Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Killing app because it never posted an incoming call to the system after receiving a PushKit VoIP push.'
*** First throw call stack:
(0x18d6152ec 0x18aa99a7c 0x18c911ea0 0x21c386494 0x105fa6064 0x105f9d19c 0x21c3856fc 0x105f8c584 0x105fa6064 0x105fc6f38 0x105f9c548 0x105f9c484 0x18d56e2b4 0x18d56c0b0 0x18d590700 0x1da0d1190 0x1901ae240 0x1901ac470 0x106e6862c 0x1b3f93ad8)
terminating due to uncaught exception of type NSException
Message from debugger: Terminated due to signal 9

И это не шутка — это действительно механизм защиты Apple от злоупотреблений (вроде скрытого VoIP-пуша без показа звонка).

? Как делать правильно

Когда в фоне прилетает пуш (pushRegistry(_:didReceiveIncomingPushWith:for:completion:)), у тебя должно быть что-то вроде:

func pushRegistry(_ registry: PKPushRegistry,
                  didReceiveIncomingPushWith payload: PKPushPayload,
                  for type: PKPushType,
                  completion: @escaping () -> Void) {
    
    // 1. Сохраняем payload, поднимаем звонок (либо инфу о нём)
    let uuid = UUID()
    let update = CXCallUpdate()
    update.remoteHandle = CXHandle(type: .generic, value: "SIP User")
    
    // 2. Репортим звонок в CallKit
    provider.reportNewIncomingCall(with: uuid, update: update) { error in
        // 3. Обязательно вызвать completion, даже если ошибка
        completion()
    }
}

❗️ Если ты забудешь reportNewIncomingCall(...) — будет падение.
❗️ Если забудешь вызвать completion() — тоже могут быть проблемы.

⚠️ Важные моменты:

  • Не "откладывай" вызов CallKit.
    iOS буквально даёт тебе пару секунд.
    Попытка "подождать SIP-регистрацию" или "разбудить Core" = ? смерть приложения.

  • Если пуш пришёл, но звонка не будет (например, по логике твоего бэкенда) — всё равно вызови reportNewIncomingCall(...), покажи звонок, и же сразу вызовиendCall(...). Это лучше, чем быть прибитым системой.

  • Даже если приложение закрыто, iOS поднимет его пушем — но только при корректном вызове reportNewIncomingCall(...) сразу после запуска.

? Как тестировать

  1. Убей приложение.

  2. Отправь VoIP push через Apple-консоль или curl.

  3. Убедись, что reportNewIncomingCall(...) вызывается моментально.

  4. Если не вызовешь — приложение будет "навсегда заглушено" на VoIP-пуши, пока не переустановишь.

Переход на TLS

Использование «голого» UDP- или TCP-транспорта для SIP и отсутствие шифрования медиапотоков могут вызывать проблемы при прохождении вызовов, т.к., например, SIP-трафик (и связанный с ним) могут ограничивать или блокировать провайдеры. В таком случае желательно перейти на схему с SIP TLS и SRTP, а также, для избежания проблем с плохими реализациями SIP ALG, изменить порт сигнализации по умолчанию (5061) на любой другой.

 guard let identityAddress = core.interpretUrl(
            url: "sip:\(config.username)@\(config.domain)",
            applyInternationalPrefix: false
        ),
              let proxyAddress = core.interpretUrl(
                  url: "<sip:\(config.domain):\(tlsPort);transport=\(config.transport)>",
                  applyInternationalPrefix: false
              )
        else {
            return
        }
        do {
            try identityAddress.setTransport(newValue: config.transport)
            try identityAddress.setPort(newValue: tlsPort)
            try setIdentityaddress(newValue: identityAddress)
            try setServeraddress(newValue: proxyAddress)
let remoteAddress = try Factory.Instance.createAddress(addr: addr)

try remoteAddress.setPort(newValue: tlsPort)

try remoteAddress.setDomain(newValue: domain)

try remoteAddress.setDisplayname(newValue: username)

// ✅ Настройка параметров звонка

let callParams = try core.createCallParams(call: nil)

callParams.audioEnabled = true

callParams.videoEnabled = true // если нужно видео

let call = core.inviteAddressWithParams(addr: remoteAddress, params: callParams)

Если задача поставлена так, что нужно просто усложнить возможность идентификации пакетов, то можно ограничиться отсутствием проверки сертификатов:

 core.verifyServerCn(yesno: false)
 core.verifyServerCertificates(yesno: false)

Заключение

Несмотря на все подводные камни, интеграция Linphone в iOS-проект оказалась относительно простой и решаемой задачей. Основные сложности возникали скорее на этапе настройки окружения и push-уведомлений, чем в самом SDK. В итоге удалось быстро получить рабочий функционал звонков с CallKit и стабильной регистрацией на SIP-сервере. Для проектов с жёсткими сроками Linphone вполне подходит как готовое решение «из коробки».

Комментарии (0)