Розроблення голосових звонків (VoIP) у мобільному застосунку
Реалізація VoIP-звонків у мобільному застосунку — не одна задача, а кілька пов'язаних технічних шарів: сигнальний протокол, медіатранспорт, управління аудіосесією та інтеграція з системними API звонків. Пропустити або спростити будь-який з цих шарів — отримати звонок, який працює в демо, але ломається в продакшені: еха, пропадання звуку при входящому сповіщенні, неможливість прийняти звонок з заблокованого екрану.
Архітектура VoIP-звонка
Голосовий звонок складається з двох незалежних потоків:
Сигналізація — керуючий канал: хто звонить, прийняти/відхилити, завершити. Передається через WebSocket, SIP або XMPP. Сигнальні повідомлення малі (JSON кілька сотень байтів), але потребують надійної доставки.
Медіапоток — аудіо між учасниками. Передається через WebRTC (RTP поверх UDP) або проприєтарні протоколи (Twilio, Vonage). UDP допускає втрату пакетівради низької затримки — це нормально для голосу. Затримка важливіше, ніж втрата 1–2% пакетів.
Самостійна реалізація медіашару — WebRTC. Managed-рішення — Twilio Voice, Vonage Voice, Agora. Різниця в складності, гнучкості та вартості.
iOS: CallKit — без нього не обійтися
На iOS VoIP без CallKit технічно можливий, але:
- Застосунок не отримує аудіосесію при входящому звонку на заблокованому екрані
- Система не показує системний екран входящого звонку (користувачи до нього привикли)
- Після iOS 13 застосунки без CallKit не отримують PushKit уведомлення для VoIP
CallKit — системний фреймворк для інтеграції VOIP-звонків у інтерфейс телефону. Показує системний екран входящого звонку, керує аудіосесією, підтримує Bluetooth/AirPods, відображає звонки в історії вишиків.
import CallKit
class CallManager: NSObject {
let provider: CXProvider
let callController = CXCallController()
init() {
let config = CXProviderConfiguration()
config.supportsVideo = false
config.maximumCallsPerCallGroup = 1
config.supportedHandleTypes = [.phoneNumber, .emailAddress]
provider = CXProvider(configuration: config)
super.init()
provider.setDelegate(self, queue: nil)
}
func reportIncomingCall(uuid: UUID, callerName: String) {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .generic, value: callerName)
update.hasVideo = false
provider.reportNewIncomingCall(with: uuid, update: update) { error in
// починаємо відповідати на звонок після дозволу системи
}
}
}
PushKit для входящих звонків у фоні. На відміну від звичайного APNs push, PushKit будить застосунок миттєво та з високим пріоритетом. Але з iOS 13 Apple вимагає негайно викликати reportNewIncomingCall при отриманні VoIP push — інакше крах застосунку. Не можна робити мережевий запит перед викликом CallKit.
Android: ConnectionService та Telecom API
Android аналог CallKit — ConnectionService з пакету android.telecom. Дозволяє застосунку стати «телефонним аккаунтом» у системі, показувати звонки на екрані блокування, керувати аудіомаршрутизацією.
Для входящих звонків у фоні — FCM push з priority: high. На Android 14+ фоновані сервіси обмежені, але ForegroundService типу phoneCall (доданий в Android 14) розв'язує саме цю задачу — у нього немає обмежень на запуск при входящому звонку.
Управління аудіосесією через AudioManager:
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
audioManager.isSpeakerphoneOn = false
// при завершенні:
audioManager.mode = AudioManager.MODE_NORMAL
Bluetooth-гарнітури — окрема головна біль. BluetoothHeadset profile, SCO з'єднання для голосу (не A2DP!), BroadcastReceiver на ACTION_SCO_AUDIO_STATE_UPDATED. Без явного управління SCO звук йде через динамік навіть при підключеній Bluetooth-гарнітурі.
WebRTC для медіашару
Якщо використовуємо WebRTC самостійно (а не через Twilio/Vonage), потрібен TURN-сервер для пробивання NAT. Без нього звонки працюють тільки в одній мережі — класичний баг на демо, який ломається у клієнта за офісним NAT.
coturn — open source TURN сервер, розгортається на VPS за кілька годин. Конфігурація ICE:
// Android WebRTC SDK
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.example.com:3478").createIceServer(),
PeerConnection.IceServer.builder("turn:turn.example.com:3478")
.setUsername("user")
.setPassword("password")
.createIceServer()
)
Кодеки: Opus для аудіо (адаптивний бітрейт, добре працює при втратах пакетів). WebRTC SDK включає його за замовчуванням.
Типові помилки в продакшені
Еха. З'являється якщо AudioManager.MODE_IN_COMMUNICATION не встановлений — система не включає echo cancellation. WebRTC SDK включає програмний AEC, але апаратний (через режим) надійніший.
Звонок переривається при входящому SMS. AVAudioSession на iOS втрачає фокус. Підпишіться на AVAudioSessionInterruptionNotification та перевключіть сесію після переривання.
Затримка > 300 мс. Зазвичай причина — TURN relay замість прямого P2P з'єднання. Перевіряємо тип ICE candidate у статистиці WebRTC: relay замість host або srflx.
Що входить в роботу
Проектуємо архітектуру під вимоги (managed SDK vs WebRTC), реалізуємо CallKit/ConnectionService інтеграцію, налаштовуємо сигнальний протокол, медіатранспорт та TURN-сервер. Тестуємо на реальних пристроях з різними мережевими умовами, Bluetooth-гарнітурами, перериваннями.
Строка: 2–5 тижнів в залежності від вибраного стека та вимог до якості звуку.







