Всем привет! Меня зовут Иван Чечиков. В этой статье я расскажу о своем пет-проекте - 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 "0":
                category = "Неизвестный абонент (не мошенник)";
                break;
            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.phone_on)
        } else {
            appImage.setImageResource(R.drawable.phone_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/phone_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. Спасибо за внимание, буду рад ответить на ваши вопросы в комментариях!

Комментарии (6)


  1. createaddressforsigning
    22.11.2024 23:15

    зашёл узнать про экранатор ... "когда экранатор экранирует символ экранации..."


    1. Coder69 Автор
      22.11.2024 23:15

      Спасибо, поправил заголовок и описание.


  1. CyberexTech
    22.11.2024 23:15

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


    1. Coder69 Автор
      22.11.2024 23:15

      Спасибо, возьму на заметку


  1. ALapinskas
    22.11.2024 23:15

    Жаль нельзя поставить автоответчик на незнакомый номер: "Ваш звонок не санкционирован, пожалуйста назовите имя абонента и цель вашего звонка."


    1. Tillman73
      22.11.2024 23:15

      А лучше автоответчик с вопросами типа - Назовите последние две цифры номера с которого вы звоните.