Пишем Android приложение для фильтрации спам-звонков

от автора

Всем привет! Меня зовут Иван Чечиков. В этой статье я расскажу о своем пет-проекте — Android приложении, которое может идентифицировать нежелательные входящие звонки.

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

Данная статья не имеет коммерческой направленности и не связана с продвижением какого-либо продукта или услуги.

Использование описанной технологии должно осуществляться строго в рамках действующего законодательства и с соблюдением прав третьих лиц.

Подробности – под катом.

Давно в 2019 году, когда приложения для фильтрации спам-звонков только появлялись в нашей стране, я проверял информацию о неизвестных входящих вызовах поиском в интернете. То есть — пропускаешь входящий звонок, вбиваешь номер в поисковую строку браузера и смотришь результаты выдачи. Если номер имел негативные отзывы и низкий рейтинг, я его блокировал и больше он меня не беспокоил. Уже тогда мне захотелось автоматизировать этот процесс в виде приложения пет-проекта, но опыта и знаний стека технологий не было.

На данный момент я работаю Full-stack QA инженером в Звуке, пишу web-автотесты на Java/Kotlin. Для создания приложения мне пришлось хорошенько покопаться в документации по разработке на Android и Интернете.

Алгоритм работы приложения по идеи от 2019 года.

  1. На стороне клиента (Android/iOS) приложение обрабатывает входящие звонки и получает телефонный номер звонящего.

  2. Отправляет GET запрос к API сайта, предоставляющего информацию о нежелательных номерах.

  3. Получает информацию от сервиса в виде json.

  4. Парсит json, получает категорию звонка.

  5. Формирует уведомление и отправляет его на экран телефона при входящем вызове.

Реализация.

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

Структура приложения в Android Studio

Структура приложения в Android Studio

Далее нужно создать три класса в директории 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *