Как мы сделали аудиозвонки в приложении для сотрудников

от автора

Меня зовут Ильдар, я техлид в команде Центра развития финансовых технологий (ЦРФТ) Россельхозбанка. Сегодня расскажу о том, как мы внедрили функцию аудиозвонков в наш корпоративный мессенджер для сотрудников.

Немного о проекте

Мы делаем приложение для сотрудников группы РСХБ, которое позволяет получить доступ к популярным корпоративным функциям: новостной ленте, кадровым сервисам, рабочему календарю, справочнику сотрудников и еще многим другим полезным вещам, которые делают рутинные процессы проще и быстрее. Так, например, мессенджер позволяет сотрудникам осуществлять быструю коммуникацию без использования внешних мессенджеров (WhatsApp и Telegram). Сразу отмечу, что это разработка на основе OpenSource-решения, а именно сервера Synapse от Matirx.

Ожидаемо, что теперь сотрудникам нужны были и звонки внутри приложения. На этом мы призадумались: можем ли мы реализовать эту функцию?..

..конечно, можем!

У нас уже был наработки в этом направлении: наша команда flutter-разработки уже проводила проверку гипотезы и нам надо было только всё соединить воедино. Но вот вопрос, с чего стоит начать?…

…конечно, со встречи!

С которой, к слову, мы ушли вот с такой картинкой:

Как это работает?

Описание работы:

  1. Когда пользователь хочет совершить звонок — он переходит в чат с тем, с кем хочет связаться.

  2. В чате он нажимает на кнопку вызова абонента.

  3. После чего отправляется запрос в матрикс на получения данных о сервере-ретрансляторе (стрелка 1).

  4. Матрикс возвращает мобильному приложению (МП) исходящего абонента информацию для подключения к серверу-ретранслятору.

  5. МП исходящего абонента отправляет запрос в Matrix на отправку в чат с вызываемого абонента служебного сообщения (не отображается пользователю) с информацией по подключению к серверу ретранслятору (стрелка 2).

  6. МП звонящего абонента отправляет запрос в Backend приложения для отправки в МП вызываемого абонента silent push-уведомления с информацией, в каком чате находятся данные для подключения к звонку (стрелка 3).

  7. Backend приложения «Цифровой офис сотрудника» отправляет silent push-уведомление в МП вызываемого абонента (стрелка 4).

  8. МП вызываемого абонента получает push-уведомление, забирает информацию из служебного сообщения чата и отображает пользователю звонок (стрелка 5).

  9. Если МП вызываемого абонента отвечает на звонок, то переходим к пункту 10. В противном случае звонок отклоняется.

  10. МП вызываемого и МП звонящего абонента обращаются к серверу-ретранслятору для получения информации об установлении соединения (стрелка 6).

  11. Матрикс отправляет сообщение в чат с вызываемым абонентом.

На фронте мобильного приложения есть flutter-библиотека для работы с Matrix, где также перечислены методы для осуществления функции аудиозвонков, поэтому тут мы использовали существующий пакет, и немного посмотрели реализацию в других проектах ?

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

О вариантах и развилках

Первым решением было использовать обычные push-уведомления, когда мы хотим сигнализировать пользователю, что ему пришло сообщение в чат.

Но при такой реализации есть существенный минус: это сообщение отображается ОС мобильного устройства. Существует вариант с нетривиальными манипуляциями над уведомлениями, чтобы скрыть стандартное уведомление и отобразить экран вызова звонка. И кроме того пользователь мог вообще отключить у приложения показ всех уведомлений.

После долгих экспериментов решили проверить идею с использованием silent push уведомлений. Они приходят на устройство, даже если приложение свёрнуто или закрыто, и запускают обработчик, в котором мы инициализируем мессенджер. Далее пользователь получает сообщение, и отображается экран с вызовом (со звуковым и вибро-сопровождением). Это решение мы не встречали на просторах Интернета, и, кажется, оно может помочь тем, кто реализует у себя функционал с похожими механизмами. Конечно, чтобы не раздражать пользователей, мы разработали на своей стороне возможность отключения уведомлений о звонках, но уже внутри нашего приложения, а не с помощью ОС.

Ещё одна потребность, с которой мы столкнулись, заключалась в передаче информации о звонке сразу в push-уведомлении, чтобы не надо было лезть в чат за дополнительной информацией. Это позволило бы сразу отвечать на звонок. Но в этом случае у нас не оставалась информация о статусе звонка: состоялся, отклонён, завершён.

Конечно, информацию о звонке можно добавить и после самого звонка, но в этом случае, есть вероятность потери информации. Ещё одна причина, почему мы решили забирать данные из чата, а не через push: мы решили оставить маневр для подключения видеозвонков, а там уже необходимо передавать больше метаданных, чем помещается в push.

Как мы это сделали?

Реализация звонка по умолчанию представляет собой обмен IP-адресами, по которым два абонента могут связаться с друг с другом. И, учитывая, что IP-адреса имеют серую адресацию и находятся за NAT-ом, такое взаимодействие возможно только внутри сети.

В Интернете любому абоненту так не позвонишь, поэтому для нахождения абонентов, которые находятся за NAT-ом, используются STUN-сервера. Они позволяют серверу внутри сети определить свой внешний адрес, способ трансляции адреса и порта во внешней сети. И уже этими данными на этапе получения информации о себе обмениваются абоненты.

Понятно, что для того, чтобы получать информацию об узлах в интернете необходимо самому там находится, т.е. иметь белый IP и не скрываться за NAT-ом. Для обхождения этого ограничения есть TURN-сервера (сервер-ретранслятор), который позволяет в любой топологии сети двум устройствам найти друг друга. Но при этом все пакеты проходят через TURN-сервер (в отличии от STUN, где сервер только сообщает абонентам адреса друг друга), и на него идёт большая нагрузка.

Мы остановились на этом варианте.

В качестве сервера-ретранслятора мы использовали coturn-сервер, а рекомендации по его настройки взяли из документации сервера Synapse, как и рекомендации по настройке самого Synapse.
Особенность работы TURN-сервера и сервера Synapse — это обмен секретами, где обе системы должны иметь одинаковый секрет (turn_shared_secret), по которому TURN-сервер определяет, что абоненты пришли от конкретного сервера Matrix. Т.к. взаимодействие абонентов идёт по UDP, и соединений может быть несколько, то необходимо открыть достаточно большое количество портов на TURN-сервере (мы открыли 10.000).

Работа с Matrix

Какие методы мы использовали при создании аудиозвонков:

  • /_matrix/client/v3/voip/turnServer — получаем информацию о turn-сервере, информацию о котором указали в настройках Synapse. Этот метод вызывается у абонента, который начинает звонок и у вызываемого абонента, когда он получает информацию о входящем звонке и принимает вызов.

Пример
GET https://host/_matrix/client/r0/voip/turnServer RESPONSE { "username": "1661934991@user11", "password": "v5p2MuHkCsapZYsNJWblUJN2nps=", "ttl": 3600, "uris": [ "turn:turn-server-host:5349?transport=udp", "turn:turn-server-host:5349?transport=tcp" ] }

  • m.call.invite — сообщение, которое мы шлём вызываемому абоненту (event в чат (room) с вызываемым абонентом), передаёт id-звонка, информацию о чате, и о том, кто совершает вызов.

Пример
PUT https://host/_matrix/client/r0/rooms/!rbjspIjsgOZDMsYzqH%3A/send/m.call.invite/txid1661931419706 REQUEST { "call_id": "cid1661931419390", "room_id": "!rbjspIjsgOZDMsYzqH", "party_id": "76950f68be5c2d42", "version": "1", "lifetime": 10000, "offer": { "sdp": "v=0\r\no=- 5446971621540926831 2 IN IP4 127.0.0.1\r\ns=-\r\nt=0 0\r\na=.......", "type": "offer" }, "caller_id": @user11", "caller_name": "Тестеров", "invitee_id": @user22", "capabilities": { "m.call.transferee": false, "m.call.dtmf": false }, "org.matrix.msc3077.sdp_stream_metadata": { "25b69ef3-efca-473a-b3c4-13b07cfe4daa": { "purpose": "m.usermedia", "audio_muted": false, "video_muted": true } } } RESPONSE {   ""event_id"": ""$cxGVWhpScYFtwKSTRjnAZe7_R-eoX2DXIze2qzMezAg"" }

  • m.call.answer — ответное сообщение, которое также отправляется в чат (room) с тем абонентом с которым начинается аудиозвонок.

Пример
PUT https://host/_matrix/client/r0/rooms/!rbjspIjsgOZDMsYzqH%3A/send/m.call.answer/txid1661931423825 REQUEST { "call_id": "cid1661931419390", "room_id": "!rbjspIjsgOZDMsYzqH", "party_id": "98668b2feb14c0d5", "version": "1", "answer": { "sdp": "v=0\r\no=- 2841543015153184625 2 IN IP4 127.0.0.1....", "type": "answer" }, "capabilities": { "m.call.transferee": false, "m.call.dtmf": false }, "org.matrix.msc3077.sdp_stream_metadata": { "6d082aa5-7e23-44d0-bb74-5c9aceae2bae": { "purpose": "m.usermedia", "audio_muted": false, "video_muted": true } } } RESPONSE { "event_id": "$sVH6wSKFg4bFUe1PWzb0NunSbX04fuFuayjqq5LMec8" }

  • m.call.select_answer — выбор сообщения в котором содержится подтверждение о начале звонка.

Пример
PUT https://host/_matrix/client/r0/rooms/!rbjspIjsgOZDMsYzqH%3A/send/m.call.select_answer/txid1661931422309 REQUEST { "call_id": "cid1661931419390", "room_id": "!rbjspIjsgOZDMsYzqH", "party_id": "76950f68be5c2d42", "version": "1", "lifetime": 10000, "selected_party_id": "98668b2feb14c0d5" } RESPONSE { "event_id": "$dQGojd7Iln2D4lijnnaOKqysLGIiWmhIc6icrkmyAMc" }

  • m.call.hangup — завершить звонок, сообщение в чат (room) для прекращения звонка.

Пример
PUT https://host/_matrix/client/r0/rooms/!rbjspIjsgOZDMsYzqH%3A/send/m.call.hangup/txid1661931433211 REQUSET { "call_id": "cid1661931419390", "room_id": "!rbjspIjsgOZDMsYzqH", "party_id": "98668b2feb14c0d5", "version": "1", "reason": "user_hangup" } RESPONSE { "event_id": "$tQ8CcvfNYBuzIgoDl1tIMKNDOnFGV02Eyzcws9WM8Aw" }

  • m.call.candidates — обмен ICE-кандидатов для установки соединения, которые отправляют друг другу оба вызывающих абонента. Отправляется при старте звонка, а также при изменении состояния абонентов, например, при переключении на другую сеть (с wi-fi на мобильную связь)

Пример
PUT https://host/_matrix/client/r0/rooms/!rbjspIjsgOZDMsYzqH%3A/send/m.call.candidates/txid1661931427153 REQUEST { "call_id": "cid1661931419390", "room_id": "!rbjspIjsgOZDMsYzqH", "party_id": "98668b2feb14c0d5", "version": "1", "candidates": [{ "candidate": "candidate:917381266 1 udp 2122260223 100.88.179.67 49140 typ host generation 0 ufrag avZP network-id 3 network-cost 900", "sdpMid": "0", "sdpMLineIndex": 0 }, { "candidate": "candidate:559267639 1 udp 2122202367 ::1 41729 typ host generation 0 ufrag avZP network-id 2", "sdpMid": "0", "sdpMLineIndex": 0 }, { "candidate": "candidate:1510613869 1 udp 2122129151 127.0.0.1 48274 typ host generation 0 ufrag avZP network-id 1", "sdpMid": "0", "sdpMLineIndex": 0 }, { "candidate": "candidate:842163049 1 udp 1686052607 185.211.159.23 18090 typ srflx raddr 100.88.179.67 rport 49140 generation 0 ufrag avZP network-id 3 network-cost 900", "sdpMid": "0", "sdpMLineIndex": 0 }, { "candidate": "candidate:1876313031 1 tcp 1518222591 ::1 44125 typ host tcptype passive generation 0 ufrag avZP network-id 2", "sdpMid": "0", "sdpMLineIndex": 0 }, { "candidate": "candidate:344579997 1 tcp 1518149375 127.0.0.1 38925 typ host tcptype passive generation 0 ufrag avZP network-id 1", "sdpMid": "0", "sdpMLineIndex": 0 }, { "candidate": "candidate:4259704537 1 udp 41885695 178.57.74.4 63897 typ relay raddr 185.211.159.23 rport 18090 generation 0 ufrag avZP network-id 3 network-cost 900", "sdpMid": "0", "sdpMLineIndex": 0 }, { "candidate": "candidate:3009810985 1 udp 25108223 178.57.74.4 64242 typ relay raddr 185.211.159.23 rport 27509 generation 0 ufrag avZP network-id 3 network-cost 900", "sdpMid": "0", "sdpMLineIndex": 0 } ] } RESPONSE { "event_id": "$tj7eYkhbVGKUTwAraxo_jNklwktKOSaxnDKpvJZBtcA" }

Конечно, мы не вызывали методы Матрикс напрямую, а использовали библиотеку, которую я упоминал выше: matrix_api_lite. Но если вы пишете не на flutter, то можно использовать сразу методы API Matrix.

Работа с WebRTC

Для взаимодействия по WebRTC мы также использовали библиотеку для flutter. Для создания звонка сперва необходим объект соединения RTCPeerConnection, который осуществит связь между устройствами.

Он содержит в себе контент (track), которым обмениваются пользователи (голос, видео) и ICE-кандидатов — адреса через которые можно передать этот контент, например, ip-адрес и порт, первоначально ICE-кандидатами мы обмениваемся через сервер Matrix.

Поэтому в объекте RTCPeerConnection мы переопределяли следующие методы:

  • onIceCandidate - срабатывает при появлении нового ICE-кандидата в RTCPeerConnection. Соответвенно другая сторона перед этим добавляет кандидатов peerConnection?.addCandidate(candidate)

  • onIceGatheringState -срабатывает, когда у ICE-кандидата меняется состояние, как только оно в статусе ready — добавляем в список кандидатов

  • onIceConnectionState - срабатывает при изменении состояния соединения, как только статус connected — фиксируем флажок, что соединение установлено

  • onTrack - срабатывает при добавлении нового контента в RTCPeerConnection

Далее мы создаём описание предложения (RTCSessionDescription) к вызываемому абоненту с помощью метода createOffer объекта RTCPeerConnection.

RTCSessionDescription description = await _peerConnection?.createOffer({}); _peerConnection?.setLocalDescription(description);

Когда мы соответственно хотим ответить мы также отправляем в другую сторону свое описание ответа, но только уже для удаленного абонента (remote):

RTCSessionDescription description = await _peerConnection?.createAnswer({}); _peerConnection?.setRemoteDescription(description);

Далее для добавления нового контента на передачу вызываемому абоненту мы используем метод addTrack, что вызывает у вызываемого абонента обработчик onTrack, где stream — это контент полученный от устройства абонента:

for (final track in stream.getTracks()) { await peerConnection!.addTrack(track, stream); }

Это первые методы, которые необходимо применить для старта звонка.

Теперь необходимо добавить слушателей чата абонентов и как только в чате появится сообщение с приглашением — мы инициализируем соответствующий description, получаем ICE-кандидатов для соединения и добавляем свой поток в peerConnection и забираем поток из RemoteStream.

Чтобы узнать побольше и посмотреть детальнее примеры по работе с библиотекой для flutter, советую почитать тут, тут и ещё вот тут.

Напоследок

Конечно, эта статья даёт представление только о базовых принципах старта звонка. Мы не описывали весь тот объём кода, который покрывает весь функционал совершения звонков, задачи по инициализации звонка, работу с устройством, состоянием. Также не описаны моменты, связанные с отключением звука, переключением на гарнитуру и прочие нюансы, которые необходимо учесть при организации звонков. Не стоит думать, что подключаешь две библиотеки и код начинает работать.

Цель статьи — дать общее представление о том, как это работает и что это вообще можно реализовать. К слову сказать, можно на этих же принципах стартовать видеозвонки, но это наши планы на будущее.

Ещё пару полезных ссылок:

https://www.postman.com/recaptime-dev/workspace/matrix-api-spec/collection/ — коллекция Postman для запросов к Матрикс-серверам

https://www.youtube.com/watch?v=3ujALMZZinE — видео о том как оразнивано ip-телефония

https://www.youtube.com/watch?v=_97j8LDmk3w — рассказ про звонки от ребят из VK


ссылка на оригинал статьи https://habr.com/ru/company/rshb/blog/722084/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *