Заметки программиста

Как использовать CallScreeningService

25.01.2026. Рекомендации от Гугла. Используйте код с осторожностью.

Класс CallScreeningService используется в Android (начиная с версии 7.0 и актуально в 2026 году) для фильтрации нежелательных звонков и идентификации вызывающего абонента до того, как телефон начнет звонить. 

Основные этапы использования

  1. Создание службы:
    Создайте класс, наследующий CallScreeningService, и переопределите метод onScreenCall(Call.Details callDetails). Этот метод вызывается системой при каждом новом входящем или исходящем вызове.
  2. Регистрация в Manifest:
    Необходимо добавить сервис в файл AndroidManifest.xml с разрешением BIND_SCREENING_SERVICE и фильтром интентов для android.telecom.CallScreeningService:xml<service android:name=".MyCallScreeningService" android:permission="android.permission.BIND_SCREENING_SERVICE" android:exported="true"> <intent-filter> <action android:name="android.telecom.CallScreeningService" /> </intent-filter> </service> Используйте код с осторожностью.
  3. Обработка вызова:
    Внутри onScreenCall вы получаете объект Call.Details, который содержит информацию о звонке (номер, статус верификации и др.). Для принятия решения используйте метод respondToCall(callDetails, response).
  4. Формирование ответа (CallResponse):
    Для ответа системе используется CallResponse.Builder, где можно задать следующие параметры:
    • setDisallowCall(boolean) — блокировать ли вызов.
    • setRejectCall(boolean) — отклонить вызов (как если бы пользователь нажал «отбой»).
    • setSkipCallLog(boolean) — не записывать вызов в журнал звонков.
    • setSkipNotification(boolean) — не показывать уведомление о пропущенном звонке.
    • setSilenceCall(boolean) — отключить звук звонка (вызов продолжается, но беззвучно). 

Важные ограничения в 2026 году

  • Роль в системе: Приложение должно запросить у пользователя роль Call Screening через RoleManager, чтобы система начала отправлять вызовы в ваш сервис.
  • Таймаут: Операции проверки должны выполняться быстро, иначе система примет решение по умолчанию (пропустит звонок), чтобы не задерживать соединение.
  • Конфиденциальность: Начиная с Android 10+, использование этого API позволяет избежать запроса разрешений на чтение журнала звонков (READ_CALL_LOG) для функций определения спама. 

Используйте:

  1. Call.Details: Этот объект передается в метод onScreenCall. Он содержит всю информацию о входящем вызове (номер, статус верификации и т.д.).
  2. CallResponse: Этот класс используется для формирования вашего ответа системе (блокировать звонок или разрешить). 

Пример кода (Kotlin):

import android.telecom.Call
import android.telecom.CallScreeningService

class MyCallScreeningService : CallScreeningService() {
    // Входящий параметр имеет тип Call.Details
    override fun onScreenCall(callDetails: Call.Details) {
        
        // 1. Получаем данные о звонке из callDetails
        val phoneNumber = callDetails.handle?.schemeSpecificPart

        // 2. Формируем ответ через CallResponse.Builder
        val response = CallResponse.Builder()
            .setDisallowCall(false) // Разрешить или запретить
            .setRejectCall(false)   // Отклонить ли вызов
            .build()

        // 3. Отправляем ответ системе
        respondToCall(callDetails, response)
    }
}

Как получить номер телефона или статус спама из объекта Call.Details

Для получения номера телефона и проверки статуса вызова из объекта Call.Details используются следующие методы:

1. Получение номера телефона

Номер телефона хранится в свойстве handle, которое представляет собой Uri. Чтобы получить строку с номером, используйте:

kotlin

override fun onScreenCall(callDetails: Call.Details) {
    // Получаем Uri (например, tel:+1234567890)
    val handle: Uri? = callDetails.handle
    
    // Извлекаем саму строку номера (без префикса "tel:")
    val phoneNumber = handle?.schemeSpecificPart
    
    // Также можно проверить направление вызова
    val isIncoming = callDetails.callDirection == Call.Details.DIRECTION_INCOMING
}

2. Проверка статуса (STIR/SHAKEN)

В Android напрямую нет поля «isSpam», так как это решение обычно принимает ваше приложение на основе своих баз. Однако система предоставляет статус верификации номера (на базе технологии STIR/SHAKEN), который помогает выявить поддельные номера: 

  • getCallerNumberVerificationStatus():
    • Connection.VERIFICATION_STATUS_PASSED: Номер успешно подтвержден сетью оператора. Скорее всего, вызов легитимен.
    • Connection.VERIFICATION_STATUS_FAILED: Верификация не удалась. Это серьезный признак того, что номер подделан (спуфинг) и вызов может быть спамом.
    • Connection.VERIFICATION_STATUS_NOT_VERIFIED: Сеть не предоставила данных о верификации. 

3. Дополнительные данные

Вы также можете проверить:

  • callDetails.getCallLogExtras(): Может содержать дополнительную информацию от системы, если она доступна.
  • Собственная база спама: Обычно разработчики берут phoneNumber и отправляют запрос на свой сервер или в локальную БД для проверки его репутации.

Важно: Помните, что CallScreeningService вызывается только для номеров, которых нет в контактах пользователя. 

Пример асинхронного запроса к API для проверки номера в базе спама перед отправкой respondToCall

Чтобы сделать асинхронную проверку, важно помнить: система ждет ответа очень недолго (обычно до 5 секунд). Если сервис не вызовет respondToCall вовремя, система пропустит звонок по умолчанию.

Для асинхронности в Kotlin лучше всего использовать Coroutines.

Пример реализации с Coroutines и Retrofit

kotlin

import android.telecom.Call
import android.telecom.CallScreeningService
import kotlinx.coroutines.*

class MyCallScreeningService : CallScreeningService() {

    private val serviceScope = CoroutineScope(Dispatchers.Main + Job())

    override fun onScreenCall(callDetails: Call.Details) {
        val phoneNumber = callDetails.handle?.schemeSpecificPart ?: return

        // Запускаем асинхронную проверку
        serviceScope.launch {
            val isSpam = checkSpamStatus(phoneNumber)

            val response = CallResponse.Builder().apply {
                if (isSpam) {
                    setDisallowCall(true)
                    setRejectCall(true)
                    setSkipCallLog(false)
                    setSkipNotification(true)
                }
            }.build()

            // Отправляем ответ системе
            respondToCall(callDetails, response)
        }
    }

    private suspend fun checkSpamStatus(phoneNumber: String): Boolean = withContext(Dispatchers.IO) {
        return@withContext try {
            // Имитация сетевого запроса к вашему API
            // val result = myApiService.checkNumber(phoneNumber)
            // result.isSpam
            false 
        } catch (e: Exception) {
            false // В случае ошибки сети лучше пропустить звонок
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        serviceScope.cancel() // Отменяем запросы при уничтожении сервиса
    }
}

Ключевые моменты:

  1. Таймаут: Рекомендуется оборачивать сетевой запрос в withTimeoutOrNull(2000) { ... }, чтобы гарантированно ответить системе, даже если ваш сервер «завис».
  2. Диспетчеры: Сам onScreenCall работает в главном потоке, поэтому сетевой запрос обязательно выносите на Dispatchers.IO.
  3. Логика «по умолчанию»: Если запрос не успел или упал с ошибкой, всегда возвращайте setDisallowCall(false), чтобы пользователь не пропустил важный звонок из-за багов сети.

Как правильно добавить таймаут и логику белого списка, чтобы не проверять номера, которые заведомо не являются спамом

Чтобы сделать сервис по-настоящему надежным и быстрым, нужно добавить локальный белый список (чтобы не дергать сеть по пустякам) и жесткий таймаут (чтобы система не «убила» сервис за медлительность).

Исправленный пример с логикой «WhiteList» и таймаутом

kotlin

import android.telecom.Call
import android.telecom.CallScreeningService
import kotlinx.coroutines.*

class MyCallScreeningService : CallScreeningService() {

    private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())

    override fun onScreenCall(callDetails: Call.Details) {
        val phoneNumber = callDetails.handle?.schemeSpecificPart ?: return

        serviceScope.launch {
            // 1. Проверяем локальный белый список (например, экстренные службы или кэш)
            if (isInLocalWhiteList(phoneNumber)) {
                allowCall(callDetails)
                return@launch
            }

            // 2. Делаем запрос в сеть с жестким таймаутом
            val isSpam = withTimeoutOrNull(3000) { // Ждем максимум 3 секунды
                checkSpamViaApi(phoneNumber)
            } ?: false // Если вышло время — считаем, что не спам (безопасный режим)

            // 3. Формируем финальный ответ
            val response = CallResponse.Builder().apply {
                if (isSpam) {
                    setDisallowCall(true)
                    setRejectCall(true)
                    setSkipNotification(true)
                }
            }.build()

            respondToCall(callDetails, response)
        }
    }

    private fun isInLocalWhiteList(number: String): Boolean {
        // Логика проверки: короткие номера, номера из вашей БД или 
        // специфические паттерны вашего региона
        val whiteListPrefixes = listOf("8800", "112") 
        return whiteListPrefixes.any { number.startsWith(it) }
    }

    private suspend fun checkSpamViaApi(number: String): Boolean = withContext(Dispatchers.IO) {
        return@withContext try {
            // Ваш Retrofit запрос здесь
            // api.checkNumber(number).isSpam
            delay(1000) // Имитация работы сети
            false
        } catch (e: Exception) {
            false
        }
    }

    private fun allowCall(callDetails: Call.Details) {
        respondToCall(callDetails, CallResponse.Builder().setDisallowCall(false).build())
    }

    override fun onDestroy() {
        serviceScope.cancel()
        super.onDestroy()
    }
}

Что здесь важно:

  1. withTimeoutOrNull(3000): Это критически важная обертка. Если ваш сервер или 4G/5G заглючит, через 3 секунды корутина вернет null, и мы просто пропустим звонок. Пользователь даже не заметит задержки. [1][4]
  2. SupervisorJob: Используется, чтобы отмена одного запроса (если вдруг прилетело два звонка подряд) не «уронила» весь Scope сервиса.
  3. Локальная база: В идеале перед запросом в сеть проверять базу данных (Room), где хранятся уже известные «хорошие» и «плохие» номера. Это экономит трафик и батарею. [3]
  4. Специфика Android 14+: На последних версиях Android система еще жестче следит за фоновыми процессами. Использование корутин в CallScreeningService — самый современный способ не блокировать основной поток и соблюсти лимиты. [4]

Как автоматически сбрасывать скрытые номера (Hidden/Private numbers) без обращения к API

Для автоматического сброса скрытых номеров достаточно проверить объект callDetails.handle. У скрытых вызовов (Private/Restricted) этот объект либо равен null, либо содержит пустую строку в поле номера.

В Android за определение «скрытости» также отвечает поле callerNumberVerificationStatus.

Код для блокировки скрытых номеров

Добавьте эту проверку в начало метода onScreenCall:

kotlin

override fun onScreenCall(callDetails: Call.Details) {
    val handle = callDetails.handle
    val phoneNumber = handle?.schemeSpecificPart

    // 1. Проверка на пустой или отсутствующий номер
    val isHidden = handle == null || phoneNumber.isNullOrBlank()
    
    // 2. Дополнительная проверка через Presentation (если доступно)
    // В некоторых случаях система помечает номер как скрытый через экстра-данные
    val isRestricted = callDetails.callLogExtras?.getInt(
        android.telecom.TelecomManager.EXTRA_CALL_DISCONNECT_CAUSE
    ) == android.telecom.DisconnectCause.RESTRICTED

    if (isHidden || isRestricted) {
        val response = CallResponse.Builder()
            .setDisallowCall(true)
            .setRejectCall(true)
            .setSkipCallLog(false) // Оставляем в журнале, чтобы пользователь видел, что кто-то звонил
            .setSkipNotification(true)
            .build()
        
        respondToCall(callDetails, response)
        return // Прекращаем выполнение, не идем в API
    }

    // Если номер есть, продолжаем обычную логику с API...
}

Почему это эффективно:

  • Мгновенно: Вы не тратите время на запуск корутин или сетевые запросы.
  • Экономия: Не расходуется лимит запросов к вашему API.
  • Безопасность: Скрытые номера чаще всего используются для спама или коллекторских звонков.

Нюанс с экстренными службами

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

Как добавить логирование заблокированных вызовов в локальную базу данных

Чтобы логировать заблокированные звонки в Room, необходимо внедрить DAO в сервис и вызвать метод записи перед отправкой ответа системе.

1. Создание Entity и DAO

Сначала опишем структуру записи в БД:

kotlin

@Entity(tableName = "blocked_calls")
data class BlockedCall(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val phoneNumber: String,
    val timestamp: Long,
    val reason: String
)

@Dao
interface BlockedCallDao {
    @Insert
    suspend fun insert(call: BlockedCall)
}

Используйте код с осторожностью.

2. Интеграция в CallScreeningService

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

kotlin

class MyCallScreeningService : CallScreeningService() {

    private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
    
    // В идеале используйте Hilt или Koin для инъекции БД
    private lateinit var database: AppDatabase 

    override fun onCreate() {
        super.onCreate()
        database = AppDatabase.getDatabase(this)
    }

    override fun onScreenCall(callDetails: Call.Details) {
        val phoneNumber = callDetails.handle?.schemeSpecificPart ?: "Unknown"

        serviceScope.launch {
            // Имитация логики определения спама
            val isSpam = true 

            if (isSpam) {
                val response = CallResponse.Builder()
                    .setDisallowCall(true)
                    .setRejectCall(true)
                    .build()

                // Сначала отвечаем системе, чтобы не задерживать вызов
                respondToCall(callDetails, response)

                // Затем сохраняем в лог (IO поток)
                saveToLog(phoneNumber, "Spam detected by API")
            } else {
                respondToCall(callDetails, CallResponse.Builder().setDisallowCall(false).build())
            }
        }
    }

    private suspend fun saveToLog(number: String, reason: String) = withContext(Dispatchers.IO) {
        val entry = BlockedCall(
            phoneNumber = number,
            timestamp = System.currentTimeMillis(),
            reason = reason
        )
        database.blockedCallDao().insert(entry)
    }

    override fun onDestroy() {
        serviceScope.cancel()
        super.onDestroy()
    }
}

На что обратить внимание:

  1. Порядок вызовов: Рекомендуется вызывать respondToCall до или одновременно с записью в БД. Если БД «затормозит», а вы не ответили системе, фильтрация не сработает.
  2. Инъекция зависимостей: В сервисах отлично работает Hilt. Это позволит не инициализировать БД в onCreate вручную.
  3. Отображение в UI: Чтобы список заблокированных номеров в приложении обновлялся мгновенно, используйте Flow или LiveData из Room в вашем ViewModel.

Как реализовать автоматическую очистку старых логов

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

1. Добавьте метод удаления в DAO

Добавьте SQL-запрос, который удаляет записи старше указанного времени:

kotlin

@Dao
interface BlockedCallDao {
    @Query("DELETE FROM blocked_calls WHERE timestamp < :threshold")
    suspend fun deleteOldLogs(threshold: Long)
    
    @Insert
    suspend fun insert(call: BlockedCall)
}

2. Создайте Worker для очистки

Этот класс будет выполнять работу в фоновом режиме:

kotlin

class CleanLogWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {

    override fun suspend doWork(): Result {
        val db = AppDatabase.getDatabase(applicationContext)
        
        // Вычисляем метку времени: текущее время минус 30 дней в миллисекундах
        val thirtyDaysAgo = System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000)
        
        return try {
            runBlocking {
                db.blockedCallDao().deleteOldLogs(thirtyDaysAgo)
            }
            Result.success()
        } catch (e: Exception) {
            Result.retry()
        }
    }
}

3. Запустите периодическую задачу

Лучше всего это сделать в методе onCreate вашего класса Application или при первом запуске приложения:

kotlin

val cleanRequest = PeriodicWorkRequestBuilder<CleanLogWorker>(1, TimeUnit.DAYS)
    .setConstraints(
        Constraints.Builder()
            .setRequiresDeviceIdle(true) // Очищать, когда пользователь не трогает телефон
            .setRequiresBatteryNotLow(true)
            .build()
    )
    .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "clean_old_logs",
    ExistingPeriodicWorkPolicy.KEEP, // Не перезапускать, если уже запланировано
    cleanRequest
)

Почему это правильный подход:

  • Производительность: Очистка не мешает работе CallScreeningService. Система сама выберет время (например, ночью на зарядке), чтобы запустить CleanLogWorker.
  • Надежность: Если телефон выключится, WorkManager доделает задачу при следующем включении.
  • Гибкость: Вы можете легко изменить срок хранения (30, 60 или 90 дней), просто поменяв константу.

Настройка Hilt, чтобы автоматически пробрасывать базу данных в сервис и воркер

Для настройки Hilt в CallScreeningService и Worker есть свои нюансы: сервисы требуют специальной аннотации, а воркеры — настройки фабрики.

1. Подготовка модуля БД

Создайте модуль для предоставления экземпляра Room:

kotlin

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
        Room.databaseBuilder(context, AppDatabase::class.java, "calls.db").build()

    @Provides
    fun provideBlockedCallDao(db: AppDatabase) = db.blockedCallDao()
}

2. Инъекция в CallScreeningService

Сервисы в Hilt помечаются аннотацией @AndroidEntryPoint. Внедрение полей происходит через @Inject.

kotlin

@AndroidEntryPoint
class MyCallScreeningService : CallScreeningService() {

    @Inject lateinit var dao: BlockedCallDao // Hilt сам подставит DAO

    private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())

    override fun onScreenCall(callDetails: Call.Details) {
        val phoneNumber = callDetails.handle?.schemeSpecificPart ?: "Unknown"
        
        serviceScope.launch {
            // Использование dao напрямую
            withContext(Dispatchers.IO) {
                dao.insert(BlockedCall(phoneNumber = phoneNumber, ...))
            }
            respondToCall(callDetails, CallResponse.Builder().build())
        }
    }
}

3. Настройка Hilt для WorkManager

Воркеры не поддерживают @Inject в конструкторе «из коробки», поэтому нужно использовать @HiltWorker.

А) Сам Worker:

kotlin

@HiltWorker
class CleanLogWorker @AssistedInject constructor(
    @Assisted context: Context,
    @Assisted params: WorkerParameters,
    private val dao: BlockedCallDao // Инъекция через конструктор
) : CoroutineWorker(context, params) {
    
    override suspend fun doWork(): Result {
        val thirtyDaysAgo = System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000)
        dao.deleteOldLogs(thirtyDaysAgo)
        return Result.success()
    }
}

Б) Настройка в Application:
Чтобы Hilt мог создавать воркеры, ваш класс Application должен реализовать интерфейс Configuration.Provider:

kotlin

@HiltAndroidApp
class MyApp : Application(), Configuration.Provider {

    @Inject lateinit var workerFactory: HiltWorkerFactory

    override fun getWorkManagerConfiguration() =
        Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .build()
}

Используйте код с осторожностью.

4. Зависимости в build.gradle

Не забудьте добавить библиотеки для поддержки Hilt в WorkManager:

gradle

implementation "androidx.hilt:hilt-work:1.2.0"
kapt "androidx.hilt:hilt-compiler:1.2.0"

Что проверить: убедитесь, что вы не вызываете WorkManager.initialize вручную в коде, так как Hilt берет это на себя через Configuration.Provider.

Как протестировать работу цепочки (Service -> Room -> Worker) без реальных звонков

Для тестирования CallScreeningService не обязательно ждать спамеров. Google предоставляет инструменты для имитации входящих вызовов, а для проверки БД и воркеров используются стандартные Android-тесты.

1. Тестирование сервиса через ADB (самый быстрый способ)

Вы можете программно заставить систему отправить вызов в ваш сервис с помощью терминала. Это проверит всю цепочку: от получения номера до записи в Room.

Команда для имитации звонка:

bash

adb shell telecom set-test-call-redirection-app <your.package.name>
adb shell am start -a android.intent.action.CALL -d tel:+79991234567

Используйте код с осторожностью.

Примечание: для CallScreeningService в новых Android иногда проще использовать Telecom Test App, но метод через am start обычно подхватывает активный Screening сервис, если он выбран в настройках «По умолчанию».


2. Unit-тестирование логики Room (Robolectric или Instrumented)

Проверьте, что DAO корректно удаляет старые записи. Используйте inMemoryDatabase, чтобы не засорять память устройства.

kotlin

@RunWith(AndroidJUnit4::class)
class DatabaseTest {
    private lateinit var db: AppDatabase
    private lateinit var dao: BlockedCallDao

    @Before
    fun createDb() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build()
        dao = db.blockedCallDao()
    }

    @Test
    fun deleteOldLogs_removesRecordsOlderThan30Days() = runBlocking {
        val oldDate = System.currentTimeMillis() - (31L * 24 * 60 * 60 * 1000)
        dao.insert(BlockedCall(phoneNumber = "123", timestamp = oldDate, reason = "Old"))
        
        val threshold = System.currentTimeMillis() - (30L * 24 * 60 * 60 * 1000)
        dao.deleteOldLogs(threshold)
        
        // Проверяем, что в базе пусто
        // val list = dao.getAll()
        // assert(list.isEmpty())
    }
}

3. Тестирование Worker (WorkManager Test Library)

Чтобы не ждать 24 часа, используйте TestListenableWorkerBuilder.

kotlin

@Test
fun testCleanLogWorker() = runBlocking {
    val worker = TestListenableWorkerBuilder<CleanLogWorker>(context)
        .setWorkerFactory(hiltWorkerFactory) // Если используете Hilt
        .build()

    val result = worker.doWork()
    assertThat(result, `is`(ListenableWorker.Result.success()))
}

4. Отладка через Layout Inspector и App Inspection

В Android Studio воспользуйтесь вкладкой App Inspection -> Database Inspector.

  1. Запустите имитацию звонка через ADB.
  2. Вы увидите в реальном времени, как в таблице blocked_calls появляется новая строка.
  3. Это лучший способ убедиться, что serviceScope.launch отработал до того, как система «прибила» процесс сервиса.

Совет: Чтобы гарантированно видеть логи в консоли, добавьте Log.d("SPAM_TEST", "Call from: $phoneNumber") в начало onScreenCall.

UI-экран со списком заблокированных номеров для визуальной проверки работы фильтра

Для создания экрана со списком заблокированных номеров лучше всего использовать Jetpack Compose в связке с Flow из Room. Это обеспечит автоматическое обновление списка при каждом новом заблокированном звонке.

1. Добавьте метод получения данных в DAO

Добавьте в ваш BlockedCallDao метод, возвращающий Flow. Room сам будет уведомлять UI об изменениях:

kotlin

@Dao
interface BlockedCallDao {
    @Query("SELECT * FROM blocked_calls ORDER BY timestamp DESC")
    fun getAllBlockedCalls(): Flow<List<BlockedCall>>
    
    // ... другие методы (insert, delete)
}

2. Создайте ViewModel

Используйте Hilt для внедрения DAO и преобразования данных для UI:

kotlin

@HiltViewModel
class BlockedCallsViewModel @Inject constructor(
    private val dao: BlockedCallDao
) : ViewModel() {
    // Автоматически обновляемый поток данных
    val blockedCalls: StateFlow<List<BlockedCall>> = dao.getAllBlockedCalls()
        .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}

3. Напишите UI на Jetpack Compose

Простой и чистый список с отображением номера, времени и причины блокировки:

kotlin

@Composable
fun BlockedCallsScreen(viewModel: BlockedCallsViewModel = hiltViewModel()) {
    val calls by viewModel.blockedCalls.collectAsState()

    Scaffold(
        topBar = { TopAppBar(title = { Text("Заблокированные звонки") }) }
    ) { padding ->
        LazyColumn(modifier = Modifier.padding(padding)) {
            items(calls) { call ->
                BlockedCallItem(call)
            }
        }
    }
}

@Composable
fun BlockedCallItem(call: BlockedCall) {
    Card(
        modifier = Modifier.fillMaxWidth().padding(8.dp),
        elevation = 4.dp
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(text = call.phoneNumber, style = MaterialTheme.typography.h6)
            Spacer(modifier = Modifier.height(4.dp))
            Text(text = "Причина: ${call.reason}", style = MaterialTheme.typography.body2)
            Text(
                text = java.text.DateFormat.getDateTimeInstance().format(call.timestamp),
                style = MaterialTheme.typography.caption,
                color = Color.Gray
            )
        }
    }
}

Как проверить работу всей цепочки:

  1. Откройте этот экран в приложении.
  2. Отправьте тестовую команду через ADB (как мы обсуждали ранее):
    adb shell am start -a android.intent.action.CALL -d tel:+79990001122
  3. Если ваш CallScreeningService определит этот номер как спам и вызовет dao.insert(), новый элемент мгновенно появится в списке без перезагрузки экрана.

В BlockedCallItem можно добавить кнопку «Разблокировать», которая будет удалять номер из БД. Это превратит ваш тестовый экран в полноценную функцию приложения.

И можно добавить функцию ручного добавления номера в черный список прямо с этого экрана

Чтобы реализовать эти функции, обновим DAOViewModel и UI. Теперь наше приложение станет полноценным менеджером черного списка.

1. Обновляем DAO

Добавим метод для удаления конкретной записи и ручной вставки:

kotlin

@Dao
interface BlockedCallDao {
    @Query("SELECT * FROM blocked_calls ORDER BY timestamp DESC")
    fun getAllBlockedCalls(): Flow<List<BlockedCall>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insert(call: BlockedCall)

    @Delete
    suspend fun delete(call: BlockedCall)
}

2. Обновляем ViewModel

Добавляем методы для обработки действий пользователя:

kotlin

@HiltViewModel
class BlockedCallsViewModel @Inject constructor(
    private val dao: BlockedCallDao
) : ViewModel() {

    val blockedCalls = dao.getAllBlockedCalls()
        .stateIn(viewModelScope, SharingStarted.Lazily, emptyList())

    fun addNumber(number: String) {
        if (number.isBlank()) return
        viewModelScope.launch(Dispatchers.IO) {
            dao.insert(BlockedCall(
                phoneNumber = number,
                timestamp = System.currentTimeMillis(),
                reason = "Добавлено вручную"
            ))
        }
    }

    fun removeCall(call: BlockedCall) {
        viewModelScope.launch(Dispatchers.IO) {
            dao.delete(call)
        }
    }
}

3. Обновляем UI (Jetpack Compose)

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

kotlin

@Composable
fun BlockedCallsScreen(viewModel: BlockedCallsViewModel = hiltViewModel()) {
    val calls by viewModel.blockedCalls.collectAsState()
    var newNumber by remember { mutableStateOf("") }

    Scaffold(
        topBar = { TopAppBar(title = { Text("Черный список") }) }
    ) { padding ->
        Column(modifier = Modifier.padding(padding)) {
            // Блок ручного добавления
            Row(modifier = Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
                TextField(
                    value = newNumber,
                    onValueChange = { newNumber = it },
                    placeholder = { Text("Введите номер") },
                    modifier = Modifier.weight(1f)
                )
                IconButton(onClick = { 
                    viewModel.addNumber(newNumber)
                    newNumber = "" 
                }) {
                    Icon(Icons.Default.Add, contentDescription = "Добавить")
                }
            }

            // Список номеров
            LazyColumn {
                items(calls) { call ->
                    BlockedCallItem(
                        call = call, 
                        onDelete = { viewModel.removeCall(call) }
                    )
                }
            }
        }
    }
}

@Composable
fun BlockedCallItem(call: BlockedCall, onDelete: () -> Unit) {
    Card(modifier = Modifier.fillMaxWidth().padding(8.dp), elevation = 4.dp) {
        Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
            Column(modifier = Modifier.weight(1f)) {
                Text(text = call.phoneNumber, style = MaterialTheme.typography.h6)
                Text(text = call.reason, style = MaterialTheme.typography.caption)
            }
            IconButton(onClick = onDelete) {
                Icon(Icons.Default.Delete, contentDescription = "Удалить", tint = Color.Red)
            }
        }
    }
}

Как это работает теперь:

  1. Ручной ввод: Вы вводите номер, и он сразу появляется в списке.
  2. Синхронизация: Как только номер добавлен в БД (вручную или через CallScreeningService), он мгновенно отобразится в LazyColumn.
  3. Разблокировка: Нажатие на иконку корзины удаляет запись. При следующем звонке с этого номера сервис его пропустит, так как записи в БД больше нет.

Маленькая хитрость: Чтобы ваш сервис «узнавал» добавленные вручную номера, убедитесь, что в onScreenCall вы проверяете БД перед тем, как лезть в API.