Всем привет! Меня зовут Иван Чечиков. В этой статье я расскажу о своем пет-проекте — Android приложении, которое может идентифицировать нежелательные входящие звонки.
Информация, представленная в данной статье, предназначена исключительно для ознакомления и не является руководством к действию. Автор не несет ответственности за любые убытки или ущерб, возникшие в результате использования описанного программного обеспечения.
Данная статья не имеет коммерческой направленности и не связана с продвижением какого-либо продукта или услуги.
Использование описанной технологии должно осуществляться строго в рамках действующего законодательства и с соблюдением прав третьих лиц.
Подробности – под катом.
Давно в 2019 году, когда приложения для фильтрации спам-звонков только появлялись в нашей стране, я проверял информацию о неизвестных входящих вызовах поиском в интернете. То есть — пропускаешь входящий звонок, вбиваешь номер в поисковую строку браузера и смотришь результаты выдачи. Если номер имел негативные отзывы и низкий рейтинг, я его блокировал и больше он меня не беспокоил. Уже тогда мне захотелось автоматизировать этот процесс в виде приложения пет-проекта, но опыта и знаний стека технологий не было.
На данный момент я работаю Full-stack QA инженером в Звуке, пишу web-автотесты на Java/Kotlin. Для создания приложения мне пришлось хорошенько покопаться в документации по разработке на Android и Интернете.
Алгоритм работы приложения по идеи от 2019 года.
-
На стороне клиента (Android/iOS) приложение обрабатывает входящие звонки и получает телефонный номер звонящего.
-
Отправляет GET запрос к API сайта, предоставляющего информацию о нежелательных номерах.
-
Получает информацию от сервиса в виде json.
-
Парсит json, получает категорию звонка.
-
Формирует уведомление и отправляет его на экран телефона при входящем вызове.
Реализация.
Для начала нужно скачать дистрибутив Android Studio и установить среду разработки. Далее создать пустой проект, используем Gradlle для подтягивания стандартных пакетов и зависимостей. Дефолтный вид структуры проекта примерно должен быть таким.
Далее нужно создать три класса в директории com.example.myapplication
HttpRequestHandler — это класс, отвечающий за сетевое взаимодействие между приложением и API сайта.
...... public class HttpRequestHandler { private static final String API_URL = "https://api.callfilter.app/apis/"; private static final String API_KEY = "API key сайта" private static final String MODE = "1"; public String getDataFromJsonString(String jsonString) throws JSONException, IOException { Map<String, String> map = new HashMap<>(); try (JsonReader reader = Json.createReader(new StringReader(jsonString))) { JsonObject jsonObject = reader.readObject(); for (String key : jsonObject.keySet()) { JsonValue value = jsonObject.get(key); if (value instanceof JsonNumber) { map.put(key, value.toString()); } else if (value instanceof JsonString) { map.put(key, ((JsonString)value).getString()); } else { throw new IllegalArgumentException("Неверный тип Json значения: " + value.getClass().getName()); } } } String category = ""; switch (Objects.requireNonNull(map.getOrDefault("cat", ""))) { case "1": category = "Мошенники"; break; case "2": category = "Реклама"; break; case "3": category = "Финансовые услуги"; break; case "4": category = "Опросы"; break; case "5": category = "Коллекторы долгов"; break; case "6": category = "Компания"; break; case "7": category = "Магазин"; break; case "8": category = "Данных об абоненте нет"; break; default: category = "Неизвестный абонент (не мошенник)"; break; } String formattedString = "Номер телефона: " + map.getOrDefault("phone", "") + "\n" + "Кто звонит: " + category; return formattedString; } public String executeGetRequest(String phoneNumber) throws IOException { URL url = new URL(API_URL + API_KEY + "/" + MODE + "/" + phoneNumber); HttpURLConnection urlConnection = (HttpURLConnection) url.openConnection(); try { int responseCode = urlConnection.getResponseCode(); if (responseCode >= 200 && responseCode < 300) { try (InputStream in = new BufferedInputStream(urlConnection.getInputStream())) { String result = readStream(in); return getDataFromJsonString(result); } catch (JSONException e) { return "Json не распарсиля"; } } else { return "Ошибка: HTTP error code: " + responseCode; } } catch (IOException e) { return "Ошибка: " + e.getMessage(); } finally { urlConnection.disconnect(); } } private String readStream(InputStream in) throws IOException { StringBuilder sb = new StringBuilder(); BufferedReader reader = new BufferedReader(new InputStreamReader(in)); String line; while ((line = reader.readLine()) != null) { sb.append(line).append("\n"); } return sb.toString().trim(); } }
Метод executeGetRequest отправляет GET-запрос к внешнему API, получает ответ, проверяет его статус-код, в случае успеха передаёт ответ в метод getDataFromJsonString для дальнейшей обработки.
Метод readStream читает данные из входного потока и преобразует их в json строку.
Метод getDataFromJsonString принимает json строку, парсит её и возвращает информацию о категории звонка.
MainActivity — класс, отображающий главный экран со всеми View-компонентами. Запускает/останавливает процессы, связанные с основной работой приложения.
...... class MainActivity : ComponentActivity() { private var isAppEnabled = false private lateinit var mServiceConnection: ServiceConnection private lateinit var requestRoleLauncher: ActivityResultLauncher<Intent> private lateinit var intentRole: Intent private lateinit var mCallServiceIntent: Intent private var isBound = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) MyCallScreeningService.STOP_WORKING = true; val appImage = findViewById<ImageView>(R.id.app_image) val appStatusText = findViewById<TextView>(R.id.app_description) appImage.setOnClickListener { isAppEnabled = !isAppEnabled if (isAppEnabled) { MyCallScreeningService.STOP_WORKING = false; Toast.makeText(this, "Приложение запускается", Toast.LENGTH_LONG).show() } else { Toast.makeText(this, "Приложение останавливается", Toast.LENGTH_LONG).show() } toggleApp(appImage) manageService() } requestRoleLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { bindMyService(appStatusText) } else { Toast.makeText( this, "Не удалось получить доступ к роли Call Screening", Toast.LENGTH_LONG ).show() } } } private fun manageService() { if (isAppEnabled) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { try { val roleManager = getSystemService(ROLE_SERVICE) as RoleManager intentRole = roleManager.createRequestRoleIntent(ROLE_CALL_SCREENING) requestRoleLauncher.launch(intentRole) } catch (e: Exception) { e.printStackTrace() Toast.makeText( this, "Ошибка при управлении приложением: ${e.message}", Toast.LENGTH_LONG ).show() } } } else { MyCallScreeningService.STOP_WORKING = true; stopService(mCallServiceIntent) unbindMyService() } } private fun bindMyService(appStatusText: TextView) { if (!isBound) { mCallServiceIntent = Intent("android.telecom.CallScreeningService") mCallServiceIntent.setPackage(applicationContext.packageName) mServiceConnection = object : ServiceConnection { override fun onServiceConnected(componentName: ComponentName, iBinder: IBinder) { Toast.makeText(this@MainActivity, "Приложение запущено", Toast.LENGTH_LONG).show() isBound = true appStatusText.text = "Приложение работает" } override fun onServiceDisconnected(componentName: ComponentName) { Toast.makeText(this@MainActivity, "Приложение остановлено", Toast.LENGTH_LONG).show() isBound = false appStatusText.text = "Приложение выключено } override fun onBindingDied(name: ComponentName) { Toast.makeText( this@MainActivity, "Связь с приложением оборвалась", Toast.LENGTH_LONG ).show() isBound = false appStatusText.text = "Сбой в работе приложения" } } bindService(mCallServiceIntent, mServiceConnection, BIND_AUTO_CREATE) } } private fun toggleApp(appImage: ImageView) { if (isAppEnabled) { appImage.setImageResource(R.drawable.app_on) } else { appImage.setImageResource(R.drawable.app_off) } } private fun unbindMyService() { if (isBound) { unbindService(mServiceConnection) isBound = false } } override fun onDestroy() { super.onDestroy() if (isBound) { unbindMyService() } } }
Метод onCreate настраивает пользовательский интерфейс. Устанавливает обработчик кликов на изображение, который меняет состояние приложения. Регистрирует колбек для получения разрешения на роль CallScreeningService.
Метод manageService управляет состоянием службы фильтрации звонков, а именно запрашивает разрешение на роль фильтрации вызовов, если приложение активно и останавливает службу, если неактивно.
-
onServiceConnected: Уведомляет о запуске приложения и устанавливает флаг isBound в true.
-
onServiceDisconnected: Уведомляет об остановке приложения и сбрасывает флаг isBound.
-
onBindingDied: Уведомляет, что связь с приложением прервана и сбрасывает isBound.
При использовании приложения пользователь видит всплывающие уведомления с помощью объекта Toast, информирующие об этапах выполнения кода.
MyCallScreeningService — класс, реализующий фильтрацию входящих звонков, создание и отправку уведомлений с данными, которое приложение получило в ответе от API.
...... public class MyCallScreeningService extends CallScreeningService { private static final int NOTIFICATION_ID = 101; private static final String CHANNEL_ID = "incoming_call_channel"; public static boolean STOP_WORKING = true; @Override public void onScreenCall(Call.Details callDetails) { if (!STOP_WORKING) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (callDetails.getCallDirection() == Call.Details.DIRECTION_INCOMING) { String phoneNumber = callDetails.getHandle().toString(). replace("tel:%2B", ""); String telephoneAccountData = fetchPhoneData(phoneNumber); showNotification(this, phoneNumber, telephoneAccountData); } } } else { return; } } private void showNotification(Context context, String number, String telephoneAccountData) { NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(android.R.drawable.sym_action_call) .setContentTitle("Входящий звонок") .setContentText("Звонит: " + number) .setStyle(new NotificationCompat.BigTextStyle() .bigText(telephoneAccountData)) .setPriority(NotificationCompat.PRIORITY_HIGH) .setCategory(NotificationCompat.CATEGORY_CALL); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { String name = "Уведомления о звонках"; String description = "Канал для уведомлений о входящих звонках"; int importance = NotificationManager.IMPORTANCE_HIGH; NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance); channel.setDescription(description); channel.setLockscreenVisibility(NotificationCompat.VISIBILITY_PUBLIC); notificationManager.createNotificationChannel(channel); if (ActivityCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { return; } notificationManager.notify(NOTIFICATION_ID, builder.build()); } } private String fetchPhoneData(String phoneNumber) { ExecutorService executorService = Executors.newSingleThreadExecutor(); Future<String> future = executorService.submit(() -> { HttpRequestHandler handler = new HttpRequestHandler(); return handler.executeGetRequest(phoneNumber); }); try { return future.get(); } catch (InterruptedException | ExecutionException e) { return e.toString(); } finally { executorService.shutdown(); } } }
Метод onScreenCall перехватывает входящие вызовы после старта приложения в MainActivity. Обрабатывает их: получает номер телефона, вызывает метод fetchPhoneData для обращения к API, категория звонка, полученная от executeGetRequest передается в метод showNotification.
Метод showNotification создает и показывает уведомление на устройстве при входящем вызове.
Конфигурационный файл приложения AndroidManifest.xml
<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.INTERNET" /> <application android:allowBackup="true" android:dataExtractionRules="@xml/data_extraction_rules" android:fullBackupContent="@xml/backup_rules" android:icon="@android:drawable/sym_action_call" android:label="@string/app_name" android:roundIcon="@android:drawable/sym_action_call" android:supportsRtl="true" android:theme="@style/Theme.MyApplication" tools:targetApi="31"> <activity android:name=".MainActivity" android:exported="true" android:label="@string/app_name" android:theme="@style/Theme.MyApplication"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> <service android:name=".MyCallScreeningService" android:exported="true" android:permission="android.permission.BIND_SCREENING_SERVICE"> <intent-filter> <action android:name="android.telecom.CallScreeningService" /> </intent-filter> </service> </application> </manifest>
Приложение при первой установке и запуске запрашивает у пользователя разрешение на фильтрацию входящих звонков.
View приложения activity_main.xml
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent" android:background="#F4EFEF"> <!-- Заголовок приложения --> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Экранатор звонков" android:textSize="24sp" android:textStyle="bold" android:textColor="#000000" android:layout_gravity="center_horizontal" android:layout_marginTop="16dp"/> <!-- Изображение-кнопка включения/выключения приложения --> <ImageView android:id="@+id/app_image" android:layout_width="400dp" android:layout_height="700dp" android:src="@drawable/app_off" android:layout_gravity="center_horizontal" android:layout_marginTop="-60dp"/> <!-- Текст состояния статуса работы приложения --> <TextView android:id="@+id/app_description" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/description_off" android:textSize="18sp" android:textColor="#000000" android:layout_gravity="center_horizontal" android:layout_marginTop="-150dp"/> </LinearLayout>
Осталось только найти API сервиса, который идентифицирует нежелательные вызовы.
В сети мне попался ресурс CallFilter, который предоставляет такое апи в открытом доступе и документацию к нему. Чтобы использовать API надо получить апи ключ, написав письмо на почту поддержки сервиса. Ребята быстро отвечают и предоставляют в случае необходимости токен для работы с их сайтом.
Как приложение работает на телефоне Samsung Galaxy A54.
Номер не из контактов имеет двойственное значение: в одном случае логическое: как раз номер не из контактов может быть нежелательным, а в другом техническое: сервис CallScreeningService перехватывает только звонки, которых нет в адресной книжки абонента.
Вот такой получился пет-проект. Это не коммерческий продукт, а эксперимент по работе с Android SDK, Java, Kotlin и библиотекой CallScreeningService. Спасибо за внимание, буду рад ответить на ваши вопросы в комментариях!
ссылка на оригинал статьи https://habr.com/ru/articles/860118/
Добавить комментарий