惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

WordPress大学
WordPress大学
The Register - Security
The Register - Security
Hugging Face - Blog
Hugging Face - Blog
博客园 - 聂微东
GbyAI
GbyAI
Recent Commits to openclaw:main
Recent Commits to openclaw:main
博客园_首页
D
Docker
S
Security @ Cisco Blogs
K
Kaspersky official blog
爱范儿
爱范儿
Simon Willison's Weblog
Simon Willison's Weblog
TaoSecurity Blog
TaoSecurity Blog
V
V2EX
C
CXSECURITY Database RSS Feed - CXSecurity.com
T
Troy Hunt's Blog
Cloudbric
Cloudbric
博客园 - 三生石上(FineUI控件)
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
The Hacker News
The Hacker News
美团技术团队
S
SegmentFault 最新的问题
L
Lohrmann on Cybersecurity
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
宝玉的分享
宝玉的分享
The Last Watchdog
The Last Watchdog
Y
Y Combinator Blog
M
MIT News - Artificial intelligence
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
Martin Fowler
Martin Fowler
Google Online Security Blog
Google Online Security Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
C
Cybersecurity and Infrastructure Security Agency CISA
T
Tor Project blog
Vercel News
Vercel News
The Cloudflare Blog
G
Google Developers Blog
T
Threat Research - Cisco Blogs
AI
AI
Stack Overflow Blog
Stack Overflow Blog
I
InfoQ
Scott Helme
Scott Helme
S
Schneier on Security
大猫的无限游戏
大猫的无限游戏
The GitHub Blog
The GitHub Blog
S
Securelist
IT之家
IT之家
Microsoft Azure Blog
Microsoft Azure Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Как я сделал групповые звонки в React Native мессенджере: WebRTC, CallKit и грабли production'а
niktomimo · 2026-05-12 · via Все публикации подряд на Хабре

Уровень сложностиСложный

Время на прочтение10 мин

Охват и читатели421

Кейс

Уровень: senior мобильная разработка и WebRTC Стек: React Native, Expo SDK 54, @livekit/react-native-webrtc, expo-notifications, CallKit, FCM Что внутри: production WebRTC с trickle ICE, VoIP push notifications, CallKit интеграция, обработка фоновых состояний

Вступление

Это третья статья из серии про инженерные решения в ONEMIX — моём мессенджере на React Native. В первой я разбирал трёхуровневый кэш сообщений, во второй — реализацию Double Ratchet E2E. Сегодня — про звонки.

Звонки в мессенджере — это та функция, которая работает либо отлично, либо никак. Пользователь привык что WhatsApp/Telegram звонят мгновенно, показывают входящие на заблокированном экране, переживают переключения Wi-Fi/LTE, и работают из фона. Если твоя реализация делает хоть что-то из этого хуже — пользователь это сразу заметит и переключится на "нормальный" мессенджер.

Я потратил несколько месяцев на то чтобы довести звонки в ONEMIX до production-уровня. В процессе пришлось изучить WebRTC изнутри, разобраться с iOS CallKit и VoIP push notifications, и собрать десяток граблей которые в туториалах не упоминают. В этой статье — как это устроено, какие решения оказались критичными, и что бы я сделал по-другому.

Сразу оговорка. Я не использую готовые SDK типа Agora, Twilio, 100ms. У них отличное качество и поддержка, но они не дают полного контроля над процессом — а для мессенджера контроль критичен. Когда звонок не проходит, пользователь винит приложение, а не "SDK от третьей стороны". Плюс готовые SDK стоят денег, которые на раннем этапе продукта лучше направить в другие места.

Архитектура: почему не "простой WebRTC"

Если загуглить "WebRTC React Native", первые десять туториалов показывают примерно одно: createPeerConnection, createOffer, setLocalDescription, собрать все ICE-кандидаты, потом отправить offer с кандидатами собеседнику. Это называется "gather-then-send" и для учебного примера работает нормально.

В production это даёт задержку 8-12 секунд от момента "позвонить" до первого аудио. Причина: современные мобильные устройства собирают ICE-кандидаты медленно — особенно в мобильных сетях, особенно если STUN/TURN серверы далеко. ICE gathering может занимать 3-5 секунд на стороне caller, потом ещё 3-5 секунд на стороне receiver. Только после этого начинается actual ICE checking и установление соединения.

Telegram и WhatsApp используют trickle ICE: offer/answer отправляются сразу после setLocalDescription, а ICE-кандидаты трикллируются по одному по мере появления. Первое соединение устанавливается как только появится первая подходящая пара кандидатов, обычно за 500-1500 мс.

Разница огромная. В gather-then-send звонок "думает" 8 секунд. В trickle ICE — полсекунды, максимум секунду.

Моя реализация trickle ICE

// ── Trickle ICE: кандидаты уходят сразу как появляются ──────────────
this.pc.onicecandidate = ({ candidate }: any) => {
  if (candidate) {
    console.log('[WebRTC] Sending ICE candidate:', candidate.type, candidate.protocol);
    this.sendSignal({ type: 'ice-candidate', candidate });
  } else {
    console.log('[WebRTC] ICE gathering complete');
  }
};

// Offer отправляется сразу после setLocalDescription
const offer = await this.pc.createOffer(offerOpts);
await this.pc.setLocalDescription(offer);
this.sendSignal({ type: 'offer', sdp: offer.sdp });  // Не ждём gather!

При получении offer получатель делает то же самое:

await this.pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp }));
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.sendSignal({ type: 'answer', sdp: answer.sdp });  // Сразу!

Кандидаты которые пришли до setRemoteDescription сохраняются в pendingCandidates и применяются пачкой после:

private async handleRemoteCandidate(candidate: any): Promise<void> {
  if (this.pc.remoteDescription) {
    await this.pc.addIceCandidate(new RTCIceCandidate(candidate));
  } else {
    // Пришёл раньше answer/offer — сохраняем
    this.pendingCandidates.push(candidate);
  }
}

Результат: время от "позвонить" до первого аудио сократилось с 8-12 секунд до 800-1500 мс. Это разница между "медленный звонок" и "нормальный звонок".

TURN credentials и почему их нужно кэшировать

WebRTC нужны STUN/TURN серверы. STUN — для определения своего внешнего IP. TURN — для relay когда direct connection невозможен (strict firewall, symmetric NAT). Настройка серверов выглядит примерно так:

const iceServers = [
  { urls: 'stun:stun.l.google.com:19302' },
  {
    urls: 'turn:my-server.com:3478',
    username: 'temp-user-12345',
    credential: 'temp-password-67890'
  }
];

TURN credentials обычно временные (живут 12-24 часа) и выдаются твоим backend'ом по запросу. Наивный подход: перед каждым звонком запросить свежие credentials с backend'а. Это плохо, потому что добавляет HTTP round-trip в критический путь. На плохой сети это ещё +1-2 секунды к задержке звонка.

Я кэширую credentials в памяти на 20 минут:

let _cachedIceServers: any[] | null = null;
let _cacheExpiry: number = 0;
const CACHE_TTL_MS = 20 * 60 * 1000;

export async function preWarmTurnCredentials(): Promise<void> {
  const turnData = await api.getTurnCredentials();
  if (turnData?.ice_servers?.length) {
    _cachedIceServers = turnData.ice_servers;
    _cacheExpiry = Date.now() + CACHE_TTL_MS;
  }
}

function getIceServers(): any[] {
  if (_cachedIceServers && Date.now() < _cacheExpiry) {
    return _cachedIceServers;
  }
  return DEFAULT_ICE_SERVERS; // fallback
}

preWarmTurnCredentials() вызывается при запуске приложения и каждые 15 минут в background. К моменту первого звонка credentials уже в памяти, HTTP-запроса нет.

Fallback на DEFAULT_ICE_SERVERS критичен. Если backend недоступен (техработы, проблемы с сетью), звонки всё равно должны работать через публичные STUN и мой статичный TURN. Качество может быть хуже, но звонки проходят.

ICE restart: что делать когда сеть пропадает

Мобильные устройства переключаются между Wi-Fi, LTE, 5G постоянно. WebRTC connection при этом может оборваться — старые ICE кандидаты указывают на предыдущий IP, а новые ещё не появились. Стандартного решения "переподключиться автоматически" у WebRTC нет.

Есть механизм ICE restart: генерируется новый offer с флагом iceRestart: true, и весь процесс ICE checking начинается заново.

this.pc.oniceconnectionstatechange = () => {
  const state = this.pc?.iceConnectionState;
  if (state === 'failed') {
    this.scheduleIceRestart();
  } else if (state === 'disconnected') {
    // Ждём 4s — может само восстановиться
    this.iceRestartTimer = setTimeout(() => {
      if (this.pc?.iceConnectionState === 'disconnected' ||
          this.pc?.iceConnectionState === 'failed') {
        this.scheduleIceRestart();
      }
    }, 4000);
  }
};

ICE restart с экспоненциальным backoff и лимитом попыток:

private scheduleIceRestart(): void {
  this.iceFailCount++;
  if (this.iceFailCount > 3) {
    this.callbacks?.onConnectionStateChange('failed');
    return;
  }

  const delay = Math.min(1000 * this.iceFailCount, 3000); // 1s, 2s, 3s
  this.iceRestartTimer = setTimeout(async () => {
    this.pc.restartIce();
    if (this.role === 'caller') {
      await this.createOffer(true); // iceRestart: true
    }
  }, delay);
}

Это спасает разговор когда пользователь переходит из дома (Wi-Fi) в машину (LTE). Без ICE restart звонок бы оборвался окончательно.

iOS CallKit и VoIP push notifications

На iOS звонки должны выглядеть как system calls. Когда приходит звонок, пользователь видит полноэкранный incoming call UI или Dynamic Island (на новых iPhone). Без этого входящие звонки выглядят как обычные push-нотификации и теряются в общем шуме.

CallKit + VoIP push — сложная тема с множеством edge case. Показываю основную логику.

Регистрация VoIP push token

// При запуске приложения
VoipPushNotification.addEventListener('register', (token: string) => {
  _voipToken = token;
  api.updateVoipToken(token); // Отправляем на backend
});
VoipPushNotification.registerVoipToken();

Backend получает VoIP token и использует его для отправки VoIP push через APNs. VoIP push отличается от обычного push тем, что может разбудить приложение из состояния "убитого системой" и запустить background task.

Background task для показа CallKit

Когда приходит VoIP push, iOS запускает background task. В этом таске нужно немедленно вызвать CallKit, иначе iOS убьёт приложение за "ложную" VoIP нотификацию:

TaskManager.defineTask(INCOMING_CALL_TASK, async ({ data, error }) => {
  const callData = data?.notification?.request?.content?.data;
  if (!callData?.call_id) return;

  const CK = require('react-native-callkeep');
  try {
    CK.displayIncomingCall(
      callData.call_id,
      callData.caller_name,
      callData.caller_name,
      'generic',
      callData.type === 'incoming_video_call'
    );
  } catch (e) {
    console.error('displayIncomingCall failed:', e);
  }
});

Этот task должен быть зарегистрирован на уровне модуля, не в React компоненте. iOS должна знать про task до получения первого push.

Результат: когда кто-то звонит пользователю, на экране появляется стандартный iOS incoming call UI, даже если приложение закрыто. Пользователь может ответить или отклонить как обычный телефонный звонок.

Групповые звонки vs peer-to-peer

Peer-to-peer звонки (один на один) технически проще: два RTCPeerConnection, один SDP exchange, один набор ICE кандидатов. Групповые звонки — другая история.

Наивный подход — mesh: каждый участник поддерживает отдельное WebRTC соединение с каждым другим участником. На 3 человека это 6 соединений (A↔B, A↔C, B↔C). На 5 человек — 20 соединений. На 10 человек — 90 соединений. Это быстро становится неуправляемым, особенно на мобильных устройствах.

Альтернатива — SFU (Selective Forwarding Unit): один центральный сервер, к которому каждый участник подключается отдельно. Участник отправляет свой аудио/видео поток на SFU, а SFU форвардит потоки всех остальных участников обратно. На 10 человек это 10 соединений клиент↔сервер вместо 90 mesh-соединений.

Я использую LiveKit как SFU. LiveKit — open-source медиасервер с WebRTC API, который можно селф-хостить. Клиентская часть подключается к LiveKit room, отправляет свой поток и получает потоки остальных участников.

// Инициация группового звонка
const initiateCall = async (groupId: string, callType: 'audio' | 'video') => {
  const result = await api.initiateGroupCall(groupId, callType);
  const callDetails = await api.getGroupCall(result.id);
  setActiveCall(callDetails);
  return callDetails;
};

// Подключение к LiveKit room
const joinLiveKitRoom = async (roomToken: string) => {
  const room = new Room();
  await room.connect(livekitUrl, roomToken);
  room.localParticipant.enableCameraAndMicrophone();
};

LiveKit выдаёт JWT token для подключения к конкретной room. Backend генерирует токен с нужными правами (publish audio/video, subscribe to others) и временем жизни. Клиент подключается к LiveKit напрямую — backend после выдачи токена в медиа-потоке не участвует.

Грабли production

Грабля 1: iOS permissions до getUserMedia

На iOS вызов getUserMedia без предварительно полученных permissions крашит приложение с "The operation couldn't be completed". На Android permissions запрашиваются системой автоматически в момент getUserMedia. Это означает platform-specific код:

// iOS: запрашиваем permissions ПЕРЕД getUserMedia
if (Platform.OS === 'ios') {
  const { Audio } = require('expo-av');
  const micResult = await Audio.requestPermissionsAsync();
  if (!micResult.granted) {
    callbacks.onError('Нет доступа к микрофону');
    return null;
  }
  if (callType === 'video') {
    const { Camera } = require('expo-camera');
    const camResult = await Camera.requestCameraPermissionsAsync();
    if (!camResult.granted) {
      callbacks.onError('Нет доступа к камере');
      return null;
    }
  }
}

Грабля 2: cleanup callbacks перед закрытием PeerConnection

При завершении звонка важен порядок операций. Если сначала закрыть RTCPeerConnection, а потом обнулить callbacks, то pc.close() сгенерирует событие onconnectionstatechange('closed'), которое попытается вызвать callback и записать состояние "closed" в UI. Следующий звонок начнётся с отображением "Соединение закрыто".

cleanup(): void {
  // Обнуляем callbacks ДО закрытия PC
  const savedWsUnsub = this.wsUnsub;
  this.callbacks = null;
  this.wsUnsub = null;

  try {
    savedWsUnsub?.();
    this.localStream?.getTracks().forEach(t => t.stop());
    this.pc?.close();
  } catch {}
}

Грабля 3: WebRTC globals в Expo

React Native по умолчанию не экспортирует WebRTC глобальные объекты (RTCPeerConnection, mediaDevices). Их нужно регистрировать через @livekit/react-native-webrtc.registerGlobals(). В Expo Go этой библиотеки нет, поэтому нужна защита от краша:

export function ensureLiveKitGlobals() {
  try {
    const webrtc = require('@livekit/react-native-webrtc');
    if (webrtc?.registerGlobals) {
      webrtc.registerGlobals();
    }
  } catch (e) {
    console.log('[WebRTC] @livekit/react-native-webrtc not available:', e);
  }
}

Вызов перед любой WebRTC операцией. В production build библиотека есть, в Expo Go приложение не крашится.

Грабля 4: race condition при быстром переключении звонков

Если пользователь быстро завершит один звонок и начнёт другой, может произойти race: cleanup первого звонка обнулит WebRTC globals в момент когда второй звонок их инициализирует. Результат: второй звонок крашится с "RTCPeerConnection is not defined".

Решение: singleton WebRTCService с мьютексом на cleanup/initialize:

class WebRTCService {
  private isCleaningUp = false;
  private isInitializing = false;

  async initialize(/* ... */) {
    if (this.isCleaningUp) {
      await new Promise(resolve => setTimeout(resolve, 100));
    }
    this.isInitializing = true;
    // ... actual initialization
    this.isInitializing = false;
  }

  cleanup() {
    if (this.isInitializing) return;
    this.isCleaningUp = true;
    // ... actual cleanup
    this.isCleaningUp = false;
  }
}

Грабля 5: Android battery optimization убивает звонки

На многих Android устройствах aggressive battery optimization убивает фоновые процессы через несколько минут. Если пользователь принял звонок и свернул приложение (например, чтобы открыть календарь), через 2-5 минут система убьёт WebRTC connection и звонок оборвётся.

Частичное решение — foreground service во время звонка:

// Android: запускаем foreground service при начале звонка
if (Platform.OS === 'android') {
  await startForegroundService({
    taskName: 'VoiceCall',
    taskTitle: 'Идёт звонок',
    taskDesc: 'ONEMIX звонок активен',
    taskIcon: { name: 'ic_launcher', type: 'mipmap' }
  });
}

Пользователь увидит постоянную нотификацию "Идёт звонок" — это показывает что приложение активно и не должно быть убито. При завершении звонка service останавливается.

Что бы я сделал по-другому

Если бы начинал сейчас с текущими знаниями, вот что изменил бы:

Начал бы с готового SDK на MVP-стадии. Agora/Twilio дают working solution за неделю вместо месяцев. Когда пользовательская база вырастет и появятся специфичные требования (кастомная UI, особая логика переключения сетей, интеграция с E2E) — тогда переписать на собственное решение. Но на старте готовый SDK выигрывает по time-to-market.

Больше внимания Android-специфике. Я сфокусировался на iOS (CallKit, VoIP push) и Android получил меньше внимания. В результате на iOS звонки выглядят как native phone calls, а на Android — как push-нотификации с кнопками. Android тоже поддерживает ConnectionService API для интеграции с system dialer, но я это не реализовал.

Медиа-качество как первоприоритетный фактор. Я оптимизировал скорость подключения и надёжность, но не уделил достаточно времени аудио/видео quality. Результат: звонки подключаются быстро, но на плохой сети качество может деградировать сильнее чем у WhatsApp. Adaptive bitrate, аудио-preprocessing, эхо-подавление — это отдельная область экспертизы.

Тестирование на широком спектре устройств. WebRTC ведёт себя по-разному на разных чипсетах, версиях Android, типах сети. У меня было 3-4 тестовых устройства, этого оказалось мало. Некоторые баги (например, эхо на Samsung Galaxy с определёнными версиями прошивки) обнаружились уже после релиза.

Заключение

Звонки в мессенджере — это 90% инженерии и 10% фич. Большая часть работы не в том чтобы "добавить кнопку звонка", а в том чтобы звонки работали предсказуемо в максимальном количестве условий: плохая сеть, переключения Wi-Fi/LTE, фоновое состояние, battery optimization, разные устройства.

Ключевые решения которые оказались критичными: trickle ICE вместо gather-then-send (разница в 8 секунд), кэширование TURN credentials (убирает HTTP round-trip), автоматический ICE restart (спасает звонки при переключении сети), правильная интеграция с CallKit на iOS (system-like UX).

Если делаете звонки в своём приложении — не недооценивайте сложность. Это не "добавим WebRTC за выходные". Но результат того стоит: когда пользователи говорят что звонки в вашем приложении "работают как в WhatsApp" — это один из лучших комплиментов качества инженерии.


Это третья статья из серии про ONEMIX. В предыдущих: трёхуровневый кэш сообщений и Double Ratchet E2E. Следующая будет про iOS App Store deployment с EAS Build — что реально происходит за кадром. В Telegram-канале пишу про разработку чаще и короче — заходите (ссылка).

Если есть вопросы по WebRTC, CallKit или конкретным кускам кода — пишите в комменты. Особенно интересно услышать от тех кто делал что-то похожее и нашёл другие решения.