Всем привет! Меня зовут Иван Чечиков. В этой статье я расскажу о своем пет-проекте - 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. Спасибо за внимание, буду рад ответить на ваши вопросы в комментариях!