25.01.2026. Рекомендации от Гугла. Используйте код с осторожностью.
- Основные этапы использования
- Важные ограничения в 2026 году
- Как получить номер телефона или статус спама из объекта Call.Details
- Пример асинхронного запроса к API для проверки номера в базе спама перед отправкой respondToCall
- Как правильно добавить таймаут и логику белого списка, чтобы не проверять номера, которые заведомо не являются спамом
- Как автоматически сбрасывать скрытые номера (Hidden/Private numbers) без обращения к API
- Как добавить логирование заблокированных вызовов в локальную базу данных
- Как реализовать автоматическую очистку старых логов
- Настройка Hilt, чтобы автоматически пробрасывать базу данных в сервис и воркер
- Как протестировать работу цепочки (Service -> Room -> Worker) без реальных звонков
- UI-экран со списком заблокированных номеров для визуальной проверки работы фильтра
- И можно добавить функцию ручного добавления номера в черный список прямо с этого экрана
Класс CallScreeningService используется в Android (начиная с версии 7.0 и актуально в 2026 году) для фильтрации нежелательных звонков и идентификации вызывающего абонента до того, как телефон начнет звонить.
Основные этапы использования
- Создание службы:
Создайте класс, наследующийCallScreeningService, и переопределите методonScreenCall(Call.Details callDetails). Этот метод вызывается системой при каждом новом входящем или исходящем вызове. - Регистрация в 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>Используйте код с осторожностью. - Обработка вызова:
ВнутриonScreenCallвы получаете объектCall.Details, который содержит информацию о звонке (номер, статус верификации и др.). Для принятия решения используйте методrespondToCall(callDetails, response). - Формирование ответа (
CallResponse):
Для ответа системе используетсяCallResponse.Builder, где можно задать следующие параметры:setDisallowCall(boolean)— блокировать ли вызов.setRejectCall(boolean)— отклонить вызов (как если бы пользователь нажал «отбой»).setSkipCallLog(boolean)— не записывать вызов в журнал звонков.setSkipNotification(boolean)— не показывать уведомление о пропущенном звонке.setSilenceCall(boolean)— отключить звук звонка (вызов продолжается, но беззвучно).
Важные ограничения в 2026 году
- Роль в системе: Приложение должно запросить у пользователя роль Call Screening через
RoleManager, чтобы система начала отправлять вызовы в ваш сервис. - Таймаут: Операции проверки должны выполняться быстро, иначе система примет решение по умолчанию (пропустит звонок), чтобы не задерживать соединение.
- Конфиденциальность: Начиная с Android 10+, использование этого API позволяет избежать запроса разрешений на чтение журнала звонков (
READ_CALL_LOG) для функций определения спама.
Используйте:
Call.Details: Этот объект передается в методonScreenCall. Он содержит всю информацию о входящем вызове (номер, статус верификации и т.д.).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() // Отменяем запросы при уничтожении сервиса
}
}
Ключевые моменты:
- Таймаут: Рекомендуется оборачивать сетевой запрос в
withTimeoutOrNull(2000) { ... }, чтобы гарантированно ответить системе, даже если ваш сервер «завис». - Диспетчеры: Сам
onScreenCallработает в главном потоке, поэтому сетевой запрос обязательно выносите наDispatchers.IO. - Логика «по умолчанию»: Если запрос не успел или упал с ошибкой, всегда возвращайте
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()
}
}
Что здесь важно:
withTimeoutOrNull(3000): Это критически важная обертка. Если ваш сервер или 4G/5G заглючит, через 3 секунды корутина вернетnull, и мы просто пропустим звонок. Пользователь даже не заметит задержки. [1][4]SupervisorJob: Используется, чтобы отмена одного запроса (если вдруг прилетело два звонка подряд) не «уронила» весь Scope сервиса.- Локальная база: В идеале перед запросом в сеть проверять базу данных (Room), где хранятся уже известные «хорошие» и «плохие» номера. Это экономит трафик и батарею. [3]
- Специфика 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()
}
}
На что обратить внимание:
- Порядок вызовов: Рекомендуется вызывать
respondToCallдо или одновременно с записью в БД. Если БД «затормозит», а вы не ответили системе, фильтрация не сработает. - Инъекция зависимостей: В сервисах отлично работает Hilt. Это позволит не инициализировать БД в
onCreateвручную. - Отображение в 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.
- Запустите имитацию звонка через ADB.
- Вы увидите в реальном времени, как в таблице
blocked_callsпоявляется новая строка. - Это лучший способ убедиться, что
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
)
}
}
}
Как проверить работу всей цепочки:
- Откройте этот экран в приложении.
- Отправьте тестовую команду через ADB (как мы обсуждали ранее):
adb shell am start -a android.intent.action.CALL -d tel:+79990001122 - Если ваш
CallScreeningServiceопределит этот номер как спам и вызоветdao.insert(), новый элемент мгновенно появится в списке без перезагрузки экрана.
В BlockedCallItem можно добавить кнопку «Разблокировать», которая будет удалять номер из БД. Это превратит ваш тестовый экран в полноценную функцию приложения.
И можно добавить функцию ручного добавления номера в черный список прямо с этого экрана
Чтобы реализовать эти функции, обновим DAO, ViewModel и 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)
}
}
}
}
Как это работает теперь:
- Ручной ввод: Вы вводите номер, и он сразу появляется в списке.
- Синхронизация: Как только номер добавлен в БД (вручную или через
CallScreeningService), он мгновенно отобразится вLazyColumn. - Разблокировка: Нажатие на иконку корзины удаляет запись. При следующем звонке с этого номера сервис его пропустит, так как записи в БД больше нет.
Маленькая хитрость: Чтобы ваш сервис «узнавал» добавленные вручную номера, убедитесь, что в onScreenCall вы проверяете БД перед тем, как лезть в API.