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

в 17:55, , рубрики: flutter, аудиозвонки, Блог компании РСХБ-Интех (Россельхозбанк), корпоративные приложения, мессенджер, мобильная разработка, приложение, Программирование, разработка мобильных приложений, рсхб, рсхб в цифре, рсхб-интех, сервисы и системы

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

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

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

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

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

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

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

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

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

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

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

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

  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=0rno=- 5446971621540926831 2 IN IP4 127.0.0.1rns=-rnt=0 0rna=.......",
"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=0rno=- 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://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js