Звонки внутри приложения с помощью SIP

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

Android предоставляет стандартные инструменты для звонков по протоколу SIP, начиная с Android 2.3. Поэтому практически все устройства (~99.5%) на данный момент поддерживают этот функционал, и вам не нужно использовать низкоуровневые библиотеки для реализации звонков.

В данной статье я буду использовать стандартное апи, но прежде чем мы приступим, я хотел бы сразу прояснить пару острых моментов. Во-первых, стандартное апи не поддерживает SRTP протокол (аналог https для sip), т. е. не обеспечивает достаточной безопасности соединения, и абоненты будут уязвимы для MITM атак. Во-вторых, в данном api присутствует ошибка, которую к сожалению никак не обойти. Проявляется она при переустановке/обновлении приложения, хотя и очень редко, и связана с тем, что сип профиль, который регистрируется в телефоне “подвисает”, не выполняет корректный логаут. Лечится только перезагрузкой устройства или в некоторых случаях сменой сети (wi-fi/мобильный интернет и наоборот). Поэтому, если вы не собираетесь организовывать секретные переговоры пользователей и часто выкладывать обновления приложения, то этот способ звонков для вас.

Весь код я выложил на гитхаб. Для вашего удобства, код также был закоммичен частями, в удобном для его разбора порядке. Вот здесь можно посмотреть инициализацию проекта. Я буду писать это приложение на котлин, если вы еще не знакомы с этим языком, предлагаю ознакомиться с ним самостоятельно, вот например хорошая серия статей. Изучение этого языка довольно полезно не столько потому, что он модный, сколько потому, что он позволяет избавиться от NullPointerException еще во время компиляции, плюс много синтаксического сахара, который сильно упрощает и ускоряет разработку, позволяя писать меньше кода. Не будьте так уверены, что jav’ы вам достаточно, не позволяйте “золотому молотку” прижать вас.


UI для звонков

Начнем с пользовательского интерфейса. Для звонков нам понадобится всего 2 экрана: экран ввода логина для звонка и сам экран вызова. На экране ввода логина нужно разместить поле для ввода sip логина и кнопку позвонить, по нажатию на которую будет начинаться звонок и открываться экран вызова. На экране вызова будет виден sip логин вашего собеседника, а также 2 или 1 кнопки: “Ответить” и “Сбросить” или “Закончить”. Да, этот экран будет использоваться как для исходящего, так и для входящего вызова. В данном приложении можно отказаться от фрагментов и оба экрана сделать как активити, но в более сложном случае отдельным активити стоило бы сделать только экран вызова.

activity_main.xml — xml c версткой для экрана ввода логина

activity_call.xml — xml c версткой для экрана вызова

Как видите оба экрана довольно просты. Здесь же давайте добавим все права для того, чтобы звонок сработал, а пользователи друг друга слышали. Следующие права обязательно должны быть в вашем файле манифеста.

В активити для ввода логина я присваиваю действие открытия активити вызова на нажатие кнопки, ставлю фокус на поле ввода, а также явно проверяю дал ли пользователь права для USE_SIP и RECORD_AUDIO, и если хотя бы одного из этих прав нет, закрываю приложение.

MainActivity.kt — активити, представляющее экран ввода логина

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

Экран ввода логина
Экран вызова

Регистрация клиента на SIP сервере

Для того чтобы звонить, вы должны завести аккаунт на каком-нибудь публичном сервере. Вы можете поднять свой сервер на Asterisk, но это уже выходит за рамки этой статьи, поэтому для простоты давайте зарегистрируемся на сервере Ekiga. Ekiga больше известна как программа для sip телефонии в Linux, но они также предоставляют бесплатные sip адресы, которые мы и будем использовать. Регистрация простейшая, как на обычном сайте, после нее ваш адрес будет выглядеть как: username@ekiga.net.

Давайте вынесем из активити всю работу с регистрацией, так же в дальнейшем будет вынесен и функционал звонков. Для этого я предлагаю создать отдельный сервис, который будет работать с SIP, а для передачи данных обратно в активити будем использовать BroadcastReceiver. Для этого всего лишь нужно послать специальный Intent через sendBroadcast(Intent intent), но прежде всего не забудьте объявить сервис в манифесте.

<service android:exported=”false” android:name=”.call.CallService”/>

Для создания интента должен использоваться уникальный action.

CallService.getIntentForStatus() — метод для передачи данных в активити через ресивер

Для того, чтобы зарегистрироваться в SIP на андроид, нужно сначала взять инстанс от SipManager, здесь же следует сразу проверить поддерживает ли устройство апи для sip, а затем уже надо инициализировать sip профиль, но там тоже есть пара острых моментов.

Во-первых, нужно передать PendingIntent с уникальным action для будущего BroadcastReceiver, который будет обрабатывать входящие звонки, подробнее об этом в следующем параграфе. Во-вторых, регистрировать listener нужно не во время открытия профиля, а после этого, т. к. иначе он не будет вызываться из-за какого бага внутри sip апи андроида. В-третьих, регистрация иногда зависает или кидает ошибку, хотя профиль успешно зарегистрирован, поэтому мы будем слать данные в активити с небольшой задержкой.

CallService.kt — основные методы для регистрации на SIP сервере

После того как сервис завершит свою работу, он должен закрыть sip профиль.

CallService.closeLocalProfile() — метод для выхода из sip профиля

Это были основные методы для регистрации на SIP сервере, полный код сервиса можно посмотреть в этом коммите. Теперь давайте посмотрим на ресивер для передачи данных от сервиса к активити. Здесь должно быть все понятно.

DataReceiver.kt — ресивер-связка между нашим sip сервисом и активити

Запускать сервис будем из MainActivity, а точнее биндить его к этой активити, т. к. на сегодня андроид, начиная с Android O, больше не поддерживает сервисы в бекграунде. Для этого нужно создать объект, имплементирующий ServiceConnection. Он, например, позволяет сохранить инстанс сервиса в момент, когда он присоединяется к активити, а также обеспечивает возможность завершить работу сервиса вместе с закрытием активити. Вместе с присоединением сервиса будем также регистрировать ресивер, а после того как он передаст статус регистрации в новый метод receiveRegisterState, можно "разрегистрировать" его.

MainActivity.kt — методы для подключения к sip сервису и отображения статуса регистрации


Входящие/исходящие вызовы

Для приема входящих звонков нам понадобится подключить библиотеку EventBus, строго говоря лучше написать свой собственный аналог на RxJava, т. к. RxJava может вам понадобиться для работы с сетью, базой данных и много чем еще, пример как сделать это, можно посмотреть здесь. Но в данном приложении тянуть RxJava для того, чтобы кинуть одно событие, нет никакой необходимости и вполне достаточно EventBus. Начнем с регистрации в манифесте BroadcastReceiver для входящих звонков, этот ресивер должен иметь уникальный action.

Сам ресивер будет посылать событие через EventBus, подписаться на которое нужно в MainActivity. Я делаю именно так, потому что при входящем звонке надо открыть экран вызова и позвать метод takeAudioCall() в уже запущенном CallService, удобнее всего это сделать в MainActivity, хотя из ресивера и нельзя передать данные в запущенную активити напрямую, можно использовать паттерн наблюдателя, который как раз и реализует EventBus.

CallReceiver.kt — ресивер, передающий событие входящего звонка в активити

CallEvent - это простейший класс данных, который содержит одно поле для Intent. Кроме этого в DataReceiver была добавлена передача статусов о звонке между сервисом к активити и наоборот.

DataReceiver.onReceive() — добавлен код для передачи статуса звонка между сервисом и активити

Помимо этого был создан еще один класс для работы со звуками. Зачем? Чтобы обеспечить дополнительную обратную связь между пользователем и приложением. Например, при входящем звонке обычно нужно не только показывать экран вызова, но и воспроизводить мелодию звонка с вибрацией, как это делает обычная звонилка. Если пользователь берет трубку или сбрасывает вызов, нужно все это дело прекратить. При исходящем звонке хорошо бы воспроизводить гудки: длинные, если собеседник не берет трубку, короткие, если он сбросил вызов и т. д. Для всего этого теперь есть класс SoundManager. Его код я приводить не буду, т. к. он не сильно относится к теме статьи. Полностью все изменения для данного этапа смотрите в этом коммите.

Вначале давайте добавим в sip сервис методы для осуществления исходящего и приема входящего звонка. Для обоих методов нужно передать свой listener, который будет правильно обрабатывать состояния звонка.

CallService.kt — методы для осуществления исходящего и приема входящего звонка

Описанные выше методы необходимо дополнить методом сброса звонка, мы же не хотим, чтобы пользователь застрял в звонке? 😃 И методом для ответа на входящий звонок, иначе придется сразу при поступлении звонка запускать аудио поток, что как-то глупо выглядит 😁 Также надо добавить публичный метод, который будет зваться из ресивера, чтобы запустить один из описанных выше методов, которые должны быть приватными.

CallService.kt — методы для завершения звонка и ответа на звонок

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

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

MainActivity.kt — методы для осуществления исходящего и приема входящего звонка

Ну и, наконец, экран вызова. Для него я добавил возможность блокировать затухание экрана и переход в режим блокировки, делается это очень просто и не требует специальных прав в манифесте. Нужно добавить в разметку активити, в корневое View, следующий атрибут: android:keepScreenOn="true".

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

CallActivity.kt — активити, представляющее экран вызова

На этом все. Для тестирования приложения, соберите его из исходников на гитхабе, если вы зарегистрировали аккаунт на Ekiga, то можете проверить его работоспособность, используя следующие номера: 500 — эхо, будет повторять все, что слышит от вас, 520 — тест входящих звонков, позвоните туда, дождитесь сброса, и вам перезвонит эхо (500).

Спасибо за внимание.