Рассмотрим реализацию еще двух UX-удобств для приложения со звонками на Android. Сначала сделаем так, чтобы приложение продолжало нормально функционировать после его сворачивания или блокировки экрана. После этого посмотрим, как можно реализовать прямые ссылки на звонок или конференцию. Нажимая на них, пользователи смартфонов попадут прямо в звонок.

Android Foreground Service

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

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

В целом механизм удобный и необходимый на мобильных устройствах. Но нам хочется обойти это ограничение, чтобы звонок был защищен от внезапного закрытия системой. К счастью, есть возможность "пометить" часть приложения как активно используемое, даже если оно уже не отображается на экране. Для этого используем Foreground Service. Отметим, что даже это не даёт полной защиты от системы -- но это повышает "приоритет" приложения в глазах системы, а заодно позволяет сохранить некоторые объекты в памяти даже при закрытии `Activity`.

Добавляем разрешение на запуск таких сервисов:

 
 uses-permission android:name="android.permission.FOREGROUND_SERVICE" /
 

Реализуем собственно наш сервис, в простейшем виде -- это просто подкласс Service, где есть ссылка на наш `CallManager` (чтобы он не был подчищен сборщиком мусора):

 
 class OngoingCallService : Service() {
    @Inject
    lateinit var abstractCallManager: AbstractCallManager
    // Реализация абстрактного метода; мы не будем использовать Bind, поэтому просто возвращаем null
    override fun onBind(intent: Intent): IBinder? = null
}
 

Сервис -- это компонент приложения, и, как и Activity, он должен быть указан в `AndroidManifest.xml`:

 
 service
    // Имя класса нашего сервиса
    android:name=".OngoingCallService"
    android:enabled="true"
    // Этот флаг означает, что другие приложения не могут запускать этот сервис
    android:exported="false"
    // Объявляем тип нашего сервиса
    android:foregroundServiceType="microphone|camera|phoneCall" /
 

Запускается наш Foreground Service немного не так, как обычные сервисы:

 
 private fun startForegroundService() {
    val intent = Intent(this, OngoingCallService::class.java)
    ContextCompat.startForegroundService(this, intent)
}
 

На версии Android выше 8 Foreground Service обязан вызвать метод startForeground в течение нескольких секунд, иначе приложение будет считаться зависшим (ANR). В этот метод необходимо передать уведомление, так как по соображениям безопасности наличие таких сервисов должно быть заметно для пользователя (если вы не знаете или забыли, как создавать уведомления -- можно освежить память в одной из наших предыдущих статей про уведомления о звонках на Андроид):

 
 val notification = getNotification()startForeground(ONGOING_NOTIFICATION_ID, notification)
 

К этому уведомлению применимо всё, что было написано в предыдущей статье об уведомлениях -- его можно обновлять списком участников звонка, добавлять в него кнопки или менять его дизайн полностью. Отличие только одно -- это уведомление будет по умолчанию `ongoing`, и пользователь не сможет его "смахнуть".

На Android 13 и выше необходимо разрешение на отображение уведомлений POST_NOTIFICATIONS. Добавляем его в манифест:

 
 uses-permission android:name="android.permission.POST_NOTIFICATIONS" /
 

Также нужно запросить это разрешение в рантайме, например при заходе в приложение. Подробнее про запрос разрешений можно почитать в документации.

Если пользователь не выдал разрешение на отображение уведомлений, то уведомление связанное с Foreground Service будет отображаться в Foreground Service (FGS) Task Manager.

Если пользователь отклоняет разрешение на уведомление, то он видит уведомления связанные с Foreground Service в Foreground Services (FGS) Task Manager, но не видит их в панели уведомлений.

Когда звонок окончен -- сервис необходимо остановить, иначе приложение можно будет полностью закрыть только через настройки, что очень неудобно для пользователей. Останавливается наш сервис так же, как и обычные сервисы:

 
 private fun stopForegroundService() {
    val intent = Intent(this, OngoingCallService::class.java)
    stopService(intent)
}
 

Запуск и остановка сервиса очень удобно реализуются, если в CallManager реализовано реактивное поле для наблюдения за состоянием звонка, например:

 
 abstractCallManager.isInCall
    .collect { if (it) startForegroundService() else stopForegroundService() }
 

Вот и вся реализация сервиса, который позволит в какой-то степени защитить наше свернутое приложение от закрытия системой.

Android Deep Links

Крайне удобная для пользователей функция, которая упрощает рост пользовательской базы приложения -- это ссылки в определённое место приложения. Если у пользователя этого приложения нет, ссылка открывает страницу в Google Play. В контексте приложений для звонков наиболее удачный вариант использования -- это возможность поделиться ссылкой на звонок / митинг / комнату. Пользователь хочет с кем-то пообщаться, кидает ссылку собеседнику, тот скачивает приложение и после этого сразу же попадает в звонок -- что может быть удобнее?

Сами по себе ссылки в конкретное место приложения поддерживаются системой без каких-либо дополнительных библиотек. Но чтобы ссылка смогла "пережить" установку приложения, нам потребуется обратиться за помощью к Firebase Dynamic Links.

Сконцентрируемся на реализации обработки ссылок в приложении, а заниматься их созданием предоставим backend-разработчикам.

Для начала добавим библиотеку:

 
 dependencies {
    implementation 'com.google.firebase:firebase-dynamic-links:20.1.1'
}
 

Для пользователя deep-links -- это обычные ссылки, на которые он нажимает. Но перед тем, как открыть ссылку в браузере, система смотрит реестр приложений и находит те, которые объявили, что они обрабатывают ссылки этого домена. Если такое приложение найдено -- вместо открытия в браузере запускается это самое приложение и ссылка передается ему. Если таких приложений больше одного -- будет показано системное окно со списком, где пользователь выберет, каким приложением ссылку открыть. Если вы владеете доменом ссылки, то можно защититься от открытия таких ссылок другими приложениями, пока установлено ваше.

Чтобы объявить ссылки, которые наше приложение может обрабатывать, надо в `AndroidManifest.xml` добавить нашему `Activity` intent-filter:

 
 activity ...
    intent-filter
        // Эти action и category сообщают системе, что мы можем "отображать" ссылки
        action android:name="android.intent.action.VIEW"/
        category android:name="android.intent.category.DEFAULT"/
        category android:name="android.intent.category.BROWSABLE"/
        // Описание ссылки, которую мы можем обрабатывать. В данном случае это ссылки, начинающиеся с calls://forasoft.com
        data
            android:host="forasoft.com"
            android:scheme="calls"/
    /intent-filter
/activity
 

Когда пользователь нажмет на Dynamic Link и установит приложение (или нажмет на ссылку с уже установленным), запустится Activity, указанная как обработчик этой ссылки. В этой Activity мы можем получить ссылку следующим образом:

 
 Firebase.dynamicLinks
        .getDynamicLink(intent)
        .addOnSuccessListener(this) { data ->
            val deepLink: Uri? = data?.link
        }
 

При использовании обычных deep links данные получаются несколько проще:

 
 val deepLink = intent?.data
 

Вот и всё, теперь остаётся лишь получить интересующие нас параметры из ссылки и произвести необходимые в вашем приложении действия для присоединения к звонку:

 
         val meetindId = deepLink?.getQueryParameter("meetingid")
        if (meetingId != null) abstractCallManager.joinMeeting(meetingId)
 

Итог

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

  • Разработка